diff --git a/.agents/roles.md b/.agents/roles.md index f0d6cea..bc53228 100644 --- a/.agents/roles.md +++ b/.agents/roles.md @@ -21,7 +21,7 @@ Treat role assignment, approved Spec handoff, final review, and verification evi | Tier | Use | Model | | --- | --- | --- | | `Primary` | Planning, implementation, public API decisions, final integration, and failed-check triage | Active primary agent | -| `SDD Gate` | Design analysis and final diff review | `gpt-5.6-sol`, `xhigh` | +| `SDD Gate` | Design analysis and final diff review | `gpt-6-astra`, `xhigh` | | `Lightweight` | Read-only review, checklist verification, CI log summaries, documentation drafts, and architecture preflight | `gpt-5.3-codex-spark`; use `gpt-5.6-luna`, `high` only when unavailable | | Role | Owner or custom agent | Tier | @@ -37,7 +37,7 @@ Treat role assignment, approved Spec handoff, final review, and verification evi ## Dispatch and Fallback Rules -- `Designer` and `Code Reviewer` use only `gpt-5.6-sol`, `xhigh`, with no Luna fallback. +- `Designer` and `Code Reviewer` use only `gpt-6-astra`, `xhigh`, with no Luna fallback. - Lightweight roles use Spark first and may use only the matching `*_luna` setting when Spark is unavailable. - A `*_luna` role must not replace `Designer`, `Code Reviewer`, `Planner`, or `Implementer`. - A side task `task_name` must exactly match the `.codex/agents/.toml` filename without its extension. diff --git a/.codex/agents/code_reviewer.toml b/.codex/agents/code_reviewer.toml index a6cc574..48cbad0 100644 --- a/.codex/agents/code_reviewer.toml +++ b/.codex/agents/code_reviewer.toml @@ -1,6 +1,6 @@ name = "code_reviewer" description = "Read-only Cradle code reviewer for correctness, regressions, public API, Macro, concurrency, scope drift, and missing verification." -model = "gpt-5.6-sol" +model = "gpt-6-astra" model_reasoning_effort = "xhigh" sandbox_mode = "read-only" developer_instructions = """ diff --git a/.codex/agents/designer.toml b/.codex/agents/designer.toml index aa0406f..c6381c7 100644 --- a/.codex/agents/designer.toml +++ b/.codex/agents/designer.toml @@ -1,6 +1,6 @@ name = "designer" description = "Read-only Cradle SDD gate for Design Brief analysis, public API boundaries, Spec acceptance criteria, verification, and minimum commit units." -model = "gpt-5.6-sol" +model = "gpt-6-astra" model_reasoning_effort = "xhigh" sandbox_mode = "read-only" developer_instructions = """ diff --git a/.gitignore b/.gitignore index ff26d37..90c43b1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ DerivedData/ xcuserdata/ *.xcuserstate +# Mermaid 개발 산출물 +.cradle/ + # 인증·환경 .netrc .env diff --git a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker new file mode 100755 index 0000000..85334dd Binary files /dev/null and b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker differ diff --git a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker new file mode 100755 index 0000000..141e56b Binary files /dev/null and b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker differ diff --git a/Artifacts/CradleDiagramMaker.artifactbundle/info.json b/Artifacts/CradleDiagramMaker.artifactbundle/info.json new file mode 100644 index 0000000..56dc922 --- /dev/null +++ b/Artifacts/CradleDiagramMaker.artifactbundle/info.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.0", + "artifacts": { + "CradleDiagramMaker": { + "version": "0.1.0", + "type": "executable", + "variants": [ + { + "path": "CradleDiagramMaker-arm64/bin/CradleDiagramMaker", + "supportedTriples": [ + "arm64-apple-macosx" + ] + }, + { + "path": "CradleDiagramMaker-x86_64/bin/CradleDiagramMaker", + "supportedTriples": [ + "x86_64-apple-macosx" + ] + } + ] + } + } +} diff --git a/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj index 2596484..5205127 100644 --- a/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj +++ b/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj @@ -165,10 +165,12 @@ A10000000000000000000006 /* Sources */, A10000000000000000000007 /* Frameworks */, A10000000000000000000008 /* Resources */, + A1000000000000000000003E /* Cradle Mermaid copy */, ); buildRules = ( ); dependencies = ( + A1000000000000000000003D /* PBXTargetDependency */, ); name = ExampleApp; packageProductDependencies = ( @@ -225,6 +227,28 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + A1000000000000000000003E /* Cradle Mermaid copy */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Cradle Mermaid copy"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"${SRCROOT}/Scripts/CopyCradleMermaid.sh\""; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ A10000000000000000000006 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -249,6 +273,13 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A1000000000000000000003D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A1000000000000000000003C /* CradlePlugin */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ A1000000000000000000000B /* Debug */ = { isa = XCBuildConfiguration; @@ -368,6 +399,10 @@ package = A1000000000000000000001B /* XCLocalSwiftPackageReference "Cradle" */; productName = Cradle; }; + A1000000000000000000003C /* CradlePlugin */ = { + isa = XCSwiftPackageProductDependency; + productName = "plugin:CradlePlugin"; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A10000000000000000000001 /* Project object */; diff --git a/Examples/ExampleApp/Scripts/CopyCradleMermaid.sh b/Examples/ExampleApp/Scripts/CopyCradleMermaid.sh new file mode 100644 index 0000000..a796269 --- /dev/null +++ b/Examples/ExampleApp/Scripts/CopyCradleMermaid.sh @@ -0,0 +1,89 @@ +#!/bin/sh + +set -eu + +search_root="${OBJROOT}/BuildToolPluginIntermediates" +destination_directory="${SRCROOT}/.cradle" +destination="${destination_directory}/DependencyGraph.mmd" +matches="${TARGET_TEMP_DIR}/cradle-diagram-paths.txt" +temporary="" + +cleanup() { + /bin/rm -f "${matches}" + + if [ -n "${temporary}" ]; then + /bin/rm -f "${temporary}" + fi +} + +trap cleanup EXIT + +: > "${matches}" + +case "${TARGET_NAME}" in + */*) + echo "warning: Xcode target 이름에 경로 구분자가 있어 기존 파일을 유지합니다." + exit 0 + ;; +esac + +target_path_pattern=$(printf '%s' "${TARGET_NAME}" | /usr/bin/sed 's/[][\\*?]/\\&/g') + +if [ -d "${search_root}" ]; then + if ! /usr/bin/find "${search_root}" \ + -type f \ + ! -type l \ + -path "*/${target_path_pattern}/CradlePlugin/CradleDiagrams/*/DependencyGraph.mmd" \ + -print > "${matches}"; then + echo "warning: Cradle DependencyGraph.mmd를 찾지 못해 기존 파일을 유지합니다." + exit 0 + fi +fi + +count=$(/usr/bin/wc -l < "${matches}" | /usr/bin/tr -d ' ') + +if [ "${count}" -ne 1 ]; then + echo "warning: Cradle DependencyGraph.mmd 후보를 하나로 결정하지 못해 기존 파일을 유지합니다." + exit 0 +fi + +source_path=$(/usr/bin/sed -n '1p' "${matches}") + +if [ ! -f "${source_path}" ] || [ -L "${source_path}" ]; then + echo "warning: Cradle DependencyGraph.mmd가 없어 기존 파일을 유지합니다." + exit 0 +fi + +if [ -L "${destination_directory}" ] || { [ -e "${destination_directory}" ] && [ ! -d "${destination_directory}" ]; }; then + echo "warning: .cradle 경로를 안전하게 쓸 수 없어 기존 파일을 유지합니다." + exit 0 +fi + +if [ -L "${destination}" ] || { [ -e "${destination}" ] && [ ! -f "${destination}" ]; }; then + echo "warning: DependencyGraph.mmd 경로를 안전하게 쓸 수 없어 기존 파일을 유지합니다." + exit 0 +fi + +if ! /bin/mkdir -p "${destination_directory}"; then + echo "warning: .cradle 디렉터리를 만들지 못해 기존 파일을 유지합니다." + exit 0 +fi + +if [ -f "${destination}" ] && /usr/bin/cmp -s "${source_path}" "${destination}"; then + exit 0 +fi + +if ! temporary=$(/usr/bin/mktemp "${destination_directory}/.DependencyGraph.mmd.XXXXXX"); then + echo "warning: Cradle DependencyGraph.mmd 임시 파일을 만들지 못해 기존 파일을 유지합니다." + exit 0 +fi + +if ! /bin/cp "${source_path}" "${temporary}"; then + echo "warning: Cradle DependencyGraph.mmd 복사에 실패해 기존 파일을 유지합니다." + exit 0 +fi + +if ! /bin/mv -f "${temporary}" "${destination}"; then + echo "warning: Cradle DependencyGraph.mmd 반영에 실패해 기존 파일을 유지합니다." + exit 0 +fi diff --git a/Package.swift b/Package.swift index 3681af3..c16f90e 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,10 @@ let package = Package( .library( name: "CradleTesting", targets: ["CradleTesting"] + ), + .plugin( + name: "CradlePlugin", + targets: ["CradlePlugin"] ) ], dependencies: [ @@ -23,6 +27,34 @@ let package = Package( ) ], targets: [ + .target( + name: "CradleGraphAnalysis", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax") + ] + ), + .target( + name: "CradleDiagramMakerSupport", + dependencies: [ + "CradleGraphAnalysis", + .product(name: "SwiftParser", package: "swift-syntax") + ] + ), + .executableTarget( + name: "CradleDiagramMakerSource", + dependencies: ["CradleDiagramMakerSupport"], + path: "Sources/CradleDiagramMaker" + ), + .binaryTarget( + name: "CradleDiagramMaker", + path: "Artifacts/CradleDiagramMaker.artifactbundle" + ), + .plugin( + name: "CradlePlugin", + capability: .buildTool(), + dependencies: ["CradleDiagramMaker"] + ), .macro( name: "CradleMacros", dependencies: [ @@ -40,6 +72,19 @@ let package = Package( dependencies: ["Cradle"], path: "Tests/CradleConsumerFixture" ), + .testTarget( + name: "CradleGraphAnalysisTests", + dependencies: [ + "CradleGraphAnalysis", + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax") + ] + ), + .testTarget( + name: "CradleDiagramMakerSupportTests", + dependencies: ["CradleDiagramMakerSupport"] + ), .testTarget( name: "CradleTests", dependencies: ["Cradle", "CradleConsumerFixture", "CradleTesting"] diff --git a/Plugins/CradlePlugin/CradlePlugin.swift b/Plugins/CradlePlugin/CradlePlugin.swift new file mode 100644 index 0000000..e33753b --- /dev/null +++ b/Plugins/CradlePlugin/CradlePlugin.swift @@ -0,0 +1,84 @@ +// +// CradlePlugin.swift +// CradlePlugin +// +// Created by opfic on 9/4/26. +// + +import Foundation +import PackagePlugin + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin +#endif + +// target source의 DependencyGraph Mermaid `.mmd` 산출물을 만드는 Build Tool Plugin +@main +struct CradlePlugin: BuildToolPlugin { + // 현재 SwiftPM source target을 단위로 Mermaid 생성 명령 구성 + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + guard let sourceTarget = target as? SwiftSourceModuleTarget else { + return [] + } + let tool = try context.tool(named: "CradleDiagramMaker") + let sourcePaths = sourceTarget.sourceFiles + .filter { $0.type == .source } + .map(\.url) + .sorted { $0.path < $1.path } + return mermaidBuildCommands( + toolURL: tool.url, + pluginWorkDirectoryURL: context.pluginWorkDirectoryURL, + moduleName: sourceTarget.moduleName, + sourceURLs: sourcePaths + ) + } +} + +#if canImport(XcodeProjectPlugin) +// Xcode 프로젝트 target의 Mermaid 생성 진입점 +extension CradlePlugin: XcodeBuildToolPlugin { + // 현재 Xcode target source를 단위로 Mermaid 생성 명령 구성 + func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + let tool = try context.tool(named: "CradleDiagramMaker") + let sourceURLs = target.inputFiles + .map(\.url) + .filter { $0.pathExtension == "swift" } + .sorted { $0.path < $1.path } + // 표시 이름을 Swift module로 해석하지 않고 단일 출력 경로 요소로 변환 + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_- ")) + let namespace = target.displayName.addingPercentEncoding(withAllowedCharacters: allowed) ?? target.id + return mermaidBuildCommands( + toolURL: tool.url, + pluginWorkDirectoryURL: context.pluginWorkDirectoryURL, + moduleName: namespace, + sourceURLs: sourceURLs + ) + } +} +#endif + +// SwiftPM·Xcode target의 입력 변경과 반복 빌드에 적용할 Mermaid 명령 구성 +private func mermaidBuildCommands( + toolURL: URL, + pluginWorkDirectoryURL: URL, + moduleName: String, + sourceURLs: [URL] +) -> [Command] { + let outputDirectory = pluginWorkDirectoryURL.appendingPathComponent("CradleDiagrams") + let arguments = [ + "--module", moduleName, + "--output", outputDirectory.path + ] + + sourceURLs.map(\.path) + + return [ + // `.mmd`를 resource로 등록하지 않고 매 빌드에서 기존 파일 정리까지 수행 + .buildCommand( + displayName: "Cradle Mermaid diagrams", + executable: toolURL, + arguments: arguments, + inputFiles: sourceURLs, + outputFiles: [] + ) + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2e00cf --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Cradle + +Cradle은 Swift Macro를 사용해 의존성 graph의 factory를 생성하는 라이브러리입니다. + +## Mermaid 개발 산출물 + +`CradlePlugin`은 build 중 Mermaid 원본을 생성합니다. Xcode에서 바로 열 파일이 필요하면 target의 마지막 Run Script 단계가 `.cradle/DependencyGraph.mmd`로 복사하게 설정할 수 있습니다. + +### ExampleApp 설정 + +`Examples/ExampleApp`의 `Cradle Mermaid copy` 단계는 [CopyCradleMermaid.sh](Examples/ExampleApp/Scripts/CopyCradleMermaid.sh)를 실행합니다. 같은 설정을 추가할 때는 이 단계를 target의 마지막 Build Phase에 두고 Based on dependency analysis를 선택 해제합니다. + +```sh +/bin/sh "${SRCROOT}/Scripts/CopyCradleMermaid.sh" +``` + +`Cmd+B`를 마치면 `Examples/ExampleApp/.cradle/DependencyGraph.mmd`가 생성됩니다. `.cradle/`은 Git에서 제외되며 앱이나 라이브러리 제품에 포함되지 않습니다. + +Xcode의 build 도구 작업 경로는 공개된 고정 경로가 아닙니다. 이 설정은 현재 `CradlePlugin` 산출물을 쉽게 열기 위한 방법이므로, Xcode 구조가 바뀐 뒤 경고가 나오면 [CopyCradleMermaid.sh](Examples/ExampleApp/Scripts/CopyCradleMermaid.sh)의 검색 경로를 확인해야 합니다. diff --git a/Scripts/build-diagram-artifact.sh b/Scripts/build-diagram-artifact.sh new file mode 100644 index 0000000..43f0d9b --- /dev/null +++ b/Scripts/build-diagram-artifact.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +# 저장소에 포함할 두 macOS host용 Maker 생성 +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_dir" +for architecture in arm64 x86_64; do + swift build -c release --product CradleDiagramMakerSource --arch "$architecture" + bin_dir="$(swift build -c release --arch "$architecture" --show-bin-path)" + artifact="Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-$architecture/bin/CradleDiagramMaker" + mkdir -p "$(dirname "$artifact")" + install -m 755 "$bin_dir/CradleDiagramMakerSource" "$artifact" + file "$artifact" +done diff --git a/Sources/Cradle/Cradle.docc/Cradle.md b/Sources/Cradle/Cradle.docc/Cradle.md index 7bda7e2..9bb8c27 100644 --- a/Sources/Cradle/Cradle.docc/Cradle.md +++ b/Sources/Cradle/Cradle.docc/Cradle.md @@ -20,6 +20,8 @@ class graph는 동시 접근을 조정하지 않습니다. 여러 Task에서 공 `sources`와 `overrides: true`를 모두 지정하지 않은 graph에서는 매크로가 생성자를 추가하지 않으며 사용자가 선언한 생성자와 인스턴스 저장 프로퍼티도 변경하지 않습니다. `sources` 또는 `overrides: true` graph는 생성 경로를 Macro가 소유합니다. +SwiftPM target에 `CradlePlugin`을 연결하면 build마다 의존성 관계를 Mermaid `.mmd` 개발 산출물로 갱신합니다. 이 산출물은 plugin work directory에만 남으며 library와 app binary에는 포함되지 않습니다. + ## Topics ### 사용 안내 @@ -28,7 +30,7 @@ class graph는 동시 접근을 조정하지 않습니다. 여러 Task에서 공 ### 매크로 -- ``DependencyGraph(sources:overrides:)`` +- ``DependencyGraph(sources:overrides:diagram:)`` - ``DependencyOverride`` - ``External`` - ``Provide()`` diff --git a/Sources/Cradle/Cradle.docc/DependencyGraph.md b/Sources/Cradle/Cradle.docc/DependencyGraph.md index 8eb6d83..a1f8eb3 100644 --- a/Sources/Cradle/Cradle.docc/DependencyGraph.md +++ b/Sources/Cradle/Cradle.docc/DependencyGraph.md @@ -359,6 +359,45 @@ shared Factory 본문은 사용자가 작성한 initializer 본문보다 먼저 source graph 저장 프로퍼티는 shared Factory에서 직접 읽을 수 있습니다. Macro는 이 참조를 생성한 static helper의 매개변수로 바꾸고, source 저장 프로퍼티를 대입한 뒤 helper를 실행합니다. source graph의 transient 값을 읽으면 그 표현식은 조합 graph를 초기화할 때 한 번 평가되어 shared 결과에 보관됩니다. +## Mermaid 개발 산출물 + +SwiftPM target의 `plugins`에 `CradlePlugin`을 추가하면 빌드마다 해당 target의 모든 `@DependencyGraph` 선언을 하나의 Mermaid 그림으로 만듭니다. 분석기는 `@Cradle.DependencyGraph`도 인식하지만 기존 Macro의 컴파일 허용 범위를 넓히지는 않습니다. + +```swift +.target( + name: "AppComposition", + dependencies: [ + .product(name: "Cradle", package: "Cradle") + ], + plugins: [ + .plugin(name: "CradlePlugin", package: "Cradle") + ] +) +``` + +target마다 `/CradleDiagrams//DependencyGraph.mmd` 하나를 생성합니다. graph별 영역 안에 provider를 표시하고 `sources` 관계를 연결해 전체 구성을 위에서 아래로 배치합니다. 공유 graph는 한 번만 표시하며 순환 관계도 중복으로 펼치지 않습니다. 서로 연결되지 않은 graph도 같은 그림에 표시합니다. 이 파일은 package source·resource가 아니며 app 또는 library binary에도 포함되지 않습니다. `swift package clean`이나 plugin work directory 정리는 산출물을 지울 수 있습니다. + +Xcode 프로젝트에서는 target의 **Build Phases → Run Build Tool Plug-ins**에 `CradlePlugin`을 추가합니다. `Examples/ExampleApp`에 연결 예제가 있습니다. Xcode에서는 target 표시 이름을 출력 디렉터리 이름으로 사용하며 경로 구분자 등은 퍼센트 인코딩합니다. 이 이름은 `PRODUCT_MODULE_NAME`을 뜻하지 않습니다. `.mmd`는 DerivedData의 plugin 작업 경로에 생성되며 DerivedData 정리 시 사라질 수 있습니다. + +plugin은 macOS용 `CradleDiagramMaker` artifact를 실행합니다. 소비자 빌드에서 분석 도구의 SwiftSyntax 의존성을 직접 빌드하지 않으며 도구는 앱이나 라이브러리에 링크되지 않습니다. 도구 소스를 수정한 뒤 저장소 루트에서 `bash Scripts/build-diagram-artifact.sh`를 실행하면 arm64·x86_64 실행 파일을 갱신할 수 있습니다. + +`.mmd`를 resource로 등록하지 않기 위해 명령의 `outputFiles`는 비워 둡니다. Xcode가 명령을 매 빌드에 실행한다는 경고를 표시할 수 있으며 이는 의도한 동작입니다. 도구는 내용이 같은 `.mmd`를 다시 쓰지 않습니다. + +`@DependencyGraph`의 `diagram` 인자는 기본값이 `true`입니다. 특정 graph를 제외할 때만 직접 작성한 `false`를 지정합니다. + +```swift +@DependencyGraph(diagram: false) +final class PreviewGraph {} +``` + +Mermaid는 graph의 `sources` 선언, provider의 타입 의존성, Factory가 실제로 읽는 source 참조를 모두 실선 화살표로 그립니다. provider 간 연결은 해당 graph 안에서만 만듭니다. 바깥 graph 묶음은 이름 없이 provider를 모으고, graph 이름 node와 source node는 중립 실선 테두리입니다. `.shared` provider node는 실선 테두리, `.transient` provider node는 점선 테두리입니다. + +source 타입은 명시한 lexical 경로가 일치하거나 바깥 lexical scope에서 같은 이름의 graph를 찾을 때 해당 graph에 연결합니다. 다른 target의 graph, 해석할 수 없는 타입, `diagram: false`로 제외한 graph는 내부 없이 source 이름만 표시합니다. module 접두사와 typealias는 의미 분석하지 않으며, 다른 scope의 이름 끝부분만 같다는 이유로 연결하지 않습니다. + +`@External` 입력이 있는 Factory는 graph가 자동으로 등록·연결하지 않는 호출 시점 생성 경로이므로 Mermaid node와 화살표에서 모두 제외합니다. runtime override 선택과 다른 target의 내부 등록은 분석하지 않습니다. + +plugin은 실제 build condition을 평가하지 않습니다. source에 작성된 모든 `#if`·`#elseif`·`#else` 절을 함께 분석하므로 같은 lexical graph가 여러 절에 선언되면 source 위치를 포함한 오류로 build를 중단하고, 기존 성공 산출물은 유지합니다. 같은 내용의 `.mmd`는 다시 쓰지 않습니다. graph 삭제·이름 변경·`diagram: false` 전환은 다음 build에서 그림에 반영하며, 대상 graph가 없으면 파일도 제거합니다. 이전 방식으로 생성한 graph별 `.mmd`는 단일 파일 생성에 성공한 뒤 정리합니다. + ## 본문 없는 Factory 본문이 없는 Factory에는 선언한 반환 타입의 initializer 호출을 자동으로 추가합니다. 매개변수의 외부 레이블과 순서는 initializer 호출에도 유지합니다. diff --git a/Sources/Cradle/DependencyGraph.swift b/Sources/Cradle/DependencyGraph.swift index 6cfb2cb..52cf900 100644 --- a/Sources/Cradle/DependencyGraph.swift +++ b/Sources/Cradle/DependencyGraph.swift @@ -14,7 +14,11 @@ // `overrides: true` graph의 인스턴스별 Factory 선택 builder와 생성 경로 추가 // source·override가 없는 graph의 initializer·stored property 미변경 @attached(member, names: arbitrary) -public macro DependencyGraph(sources: [Any.Type] = [], overrides: Bool = false) = #externalMacro( +public macro DependencyGraph( + sources: [Any.Type] = [], + overrides: Bool = false, + diagram: Bool = true +) = #externalMacro( module: "CradleMacros", type: "DependencyGraphMacro" ) diff --git a/Sources/CradleDiagramMaker/CradleDiagramMaker.swift b/Sources/CradleDiagramMaker/CradleDiagramMaker.swift new file mode 100644 index 0000000..4d0fd2b --- /dev/null +++ b/Sources/CradleDiagramMaker/CradleDiagramMaker.swift @@ -0,0 +1,52 @@ +// +// CradleDiagramMaker.swift +// CradleDiagramMaker +// +// Created by opfic on 9/4/26. +// + +import CradleDiagramMakerSupport +import Foundation + +// SwiftPM Build Tool Plugin이 호출하는 Mermaid 산출물 생성 실행 파일 +@main +struct CradleDiagramMakerCommand { + // 명령행 입력을 검증하고 실제 산출물 디렉터리 출력 + static func main() { + do { + let command = try diagramMakerCommand(arguments: Array(CommandLine.arguments.dropFirst())) + let outputs = try DiagramOutputWriter().write(request: command.request) + print("Cradle Mermaid output: \(command.request.outputDirectoryURL.appendingPathComponent(command.request.moduleName).path)") + for output in outputs { + print(output.path) + } + } catch { + // 실패 원인과 source 위치를 읽을 수 있는 도구 오류로 보고 + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } +} + +// Build Tool Plugin 인자를 산출물 요청과 선언 output file로 변환 +private func diagramMakerCommand(arguments: [String]) throws -> DiagramMakerCommand { + guard 4 <= arguments.count, + arguments[0] == "--module", + arguments[2] == "--output", + arguments[4...].allSatisfy({ !$0.hasPrefix("--") }) else { + throw DiagramOutputError.invalidArguments + } + return DiagramMakerCommand( + request: DiagramOutputRequest( + moduleName: arguments[1], + sourceURLs: arguments.dropFirst(4).map { URL(fileURLWithPath: $0) }, + outputDirectoryURL: URL(fileURLWithPath: arguments[3]) + ) + ) +} + +// Mermaid 생성 요청을 명령행 분석 결과로 보관 +private struct DiagramMakerCommand { + // source와 출력 디렉터리를 포함한 Mermaid 생성 요청 + let request: DiagramOutputRequest +} diff --git a/Sources/CradleDiagramMakerSupport/DiagramOutputWriter.swift b/Sources/CradleDiagramMakerSupport/DiagramOutputWriter.swift new file mode 100644 index 0000000..f484eb0 --- /dev/null +++ b/Sources/CradleDiagramMakerSupport/DiagramOutputWriter.swift @@ -0,0 +1,172 @@ +// +// DiagramOutputWriter.swift +// CradleDiagramMakerSupport +// +// Created by opfic on 9/4/26. +// + +import Foundation +import CradleGraphAnalysis +import SwiftParser + +// CradleDiagramMaker의 입력 source와 산출물 디렉터리 +package struct DiagramOutputRequest { + package let moduleName: String + package let sourceURLs: [URL] + package let outputDirectoryURL: URL + + package init(moduleName: String, sourceURLs: [URL], outputDirectoryURL: URL) { + self.moduleName = moduleName + self.sourceURLs = sourceURLs + self.outputDirectoryURL = outputDirectoryURL + } +} + +// Mermaid `.mmd` 산출물 생성 중단 사유 +package enum DiagramOutputError: LocalizedError { + case duplicateLexicalGraph(name: String, locations: [DiagramSourceLocation]) + case duplicateSourceAccessor(graph: String, name: String, types: [String]) + case invalidModuleName(String) + case invalidSource(URL) + case invalidArguments + + package var errorDescription: String? { + switch self { + case let .duplicateLexicalGraph(name, locations): + let paths = locations.map(\.description).joined(separator: ", ") + return "중복된 DependencyGraph lexical 이름 `\(name)`을 찾았습니다: \(paths)" + case let .duplicateSourceAccessor(graph, name, types): + return "`\(graph)`에서 중복된 source accessor `\(name)`을 찾았습니다: \(types.joined(separator: ", "))" + case let .invalidModuleName(name): + return "Mermaid 출력 이름은 비어 있지 않은 단일 경로 요소여야 합니다: \(name)" + case let .invalidSource(url): + return "DependencyGraph Mermaid 분석을 할 수 없는 Swift source입니다: \(url.path)" + case .invalidArguments: + return "사용법: CradleDiagramMaker --module --output ..." + } + } +} + +// source graph 선언 위치를 보존한 Mermaid 산출물 충돌 정보 +package struct DiagramSourceLocation: Hashable, CustomStringConvertible { + package let sourceURL: URL + package let offset: Int + + package var description: String { + "\(sourceURL.path):\(offset)" + } +} + +// 생성 전 전체 분석을 끝내고 tool 소유 `.mmd` 파일만 갱신하는 writer +package struct DiagramOutputWriter { + package init() {} + + @discardableResult + package func write(request: DiagramOutputRequest) throws -> [URL] { + try validateModuleName(request.moduleName) + let collected = try collectDiagrams(from: request.sourceURLs) + let diagrams = collected.diagrams + try validateUniqueLexicalNames(diagrams) + try validateUniqueSourceAccessors(diagrams) + let directory = request.outputDirectoryURL.appendingPathComponent(request.moduleName) + let output = directory.appendingPathComponent("DependencyGraph.mmd") + let outputs = diagrams.isEmpty ? [] : [output] + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if !diagrams.isEmpty { + try writeIfChanged(content: mermaidDiagram( + for: diagrams.map(\.diagram), excludedNames: collected.excludedNames), to: output) + } + try removeStaleOutputs( + in: directory, + keeping: Set(outputs.map { $0.resolvingSymlinksInPath().path }) + ) + return outputs + } + + // SwiftPM module 또는 Xcode target 이름의 경로 이탈 차단 + private func validateModuleName(_ moduleName: String) throws { + guard !moduleName.isEmpty, moduleName != ".", moduleName != "..", + !moduleName.contains("/"), !moduleName.contains("\\"), !moduleName.contains("\0") else { + throw DiagramOutputError.invalidModuleName(moduleName) + } + } + + private func validateUniqueSourceAccessors(_ diagrams: [CollectedDiagram]) throws { + for diagram in diagrams { + let duplicates = Dictionary(grouping: diagram.diagram.sources, by: \.name) + .filter { 1 < $0.value.count } + .sorted { $0.key < $1.key } + guard let duplicate = duplicates.first else { + continue + } + throw DiagramOutputError.duplicateSourceAccessor( + graph: diagram.diagram.lexicalName, + name: duplicate.key, + types: duplicate.value.map(\.typeName).sorted() + ) + } + } + + private func collectDiagrams(from sourceURLs: [URL]) throws -> ( + diagrams: [CollectedDiagram], excludedNames: Set + ) { + var diagrams = [CollectedDiagram]() + var excludedNames = Set() + for url in sourceURLs.sorted(by: { $0.path < $1.path }) { + let source = try String(contentsOf: url, encoding: .utf8) + let sourceFile = Parser.parse(source: source) + guard !sourceFile.hasError else { + throw DiagramOutputError.invalidSource(url) + } + let collection = graphDiagramCollection(in: sourceFile) + excludedNames.formUnion(collection.excludedNames) + diagrams += collection.diagrams.map { diagram in + CollectedDiagram( + diagram: diagram, + location: DiagramSourceLocation(sourceURL: url, offset: diagram.sourceOffset) + ) + } + } + return (diagrams, excludedNames) + } + + private func validateUniqueLexicalNames(_ diagrams: [CollectedDiagram]) throws { + let duplicates = Dictionary(grouping: diagrams, by: { $0.diagram.lexicalName }) + .filter { 1 < $0.value.count } + .sorted { $0.key < $1.key } + guard let duplicate = duplicates.first else { + return + } + throw DiagramOutputError.duplicateLexicalGraph( + name: duplicate.key, + locations: duplicate.value.map(\.location) + ) + } + + private func writeIfChanged(content: String, to url: URL) throws { + let data = Data(content.utf8) + if let existing = try? Data(contentsOf: url), existing == data { + return + } + try data.write(to: url, options: .atomic) + } + + private func removeStaleOutputs(in directory: URL, keeping paths: Set) throws { + let contents = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + for url in contents where url.pathExtension == "mmd" + && !paths.contains(url.resolvingSymlinksInPath().path) { + try FileManager.default.removeItem(at: url) + } + } +} + +// source 분석 결과와 충돌 위치를 함께 보관 +private struct CollectedDiagram { + // Mermaid 산출물로 변환할 정적 graph 모델 + let diagram: GraphDiagram + // 중복 graph 진단에 사용할 원본 source 위치 + let location: DiagramSourceLocation +} diff --git a/Sources/CradleGraphAnalysis/GraphAccessorName.swift b/Sources/CradleGraphAnalysis/GraphAccessorName.swift new file mode 100644 index 0000000..e25b44a --- /dev/null +++ b/Sources/CradleGraphAnalysis/GraphAccessorName.swift @@ -0,0 +1,80 @@ +// +// GraphAccessorName.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntax +import SwiftSyntaxBuilder + +// source graph 저장 프로퍼티 이름과 provider 접근자 이름 생성 +package func graphAccessorName(for type: TypeSyntax) -> String? { + let parenthesizedType = unwrappedGraphAccessorType(type) + let type = if let existential = parenthesizedType.as(SomeOrAnyTypeSyntax.self), + existential.someOrAnySpecifier.tokenKind == .keyword(.any) { + unwrappedGraphAccessorType(existential.constraint) + } else { + parenthesizedType + } + guard let identifier = graphTerminalIdentifier(in: type) else { + return nil + } + return graphLowerCamelCase(identifier) +} + +// Swift 예약어와 겹치지 않는 graph 접근자 이름 확인 +package func graphHasValidAccessorName(_ name: String) -> Bool { + guard let declaration = try? FunctionDeclSyntax("func \(raw: name)() {}") else { + return false + } + return !declaration.hasError +} + +// module-qualified 타입을 포함한 마지막 명목 타입 identifier 읽기 +private func graphTerminalIdentifier(in type: TypeSyntax) -> String? { + if let identifier = type.as(IdentifierTypeSyntax.self) { + return identifier.name.text + } + if let member = type.as(MemberTypeSyntax.self), + graphTerminalIdentifier(in: member.baseType) != nil { + return member.name.text + } + return nil +} + +// 프로퍼티 이름 분석에서 단일 바깥 괄호 제거 +private func unwrappedGraphAccessorType(_ type: TypeSyntax) -> TypeSyntax { + guard let tuple = type.as(TupleTypeSyntax.self), + tuple.elements.count == 1, + let element = tuple.elements.first, + element.firstName == nil, + element.secondName == nil, + element.ellipsis == nil else { + return type + } + return unwrappedGraphAccessorType(element.type) +} + +// 앞 대문자 묶음을 보존하는 lowerCamelCase 접근자 이름 생성 +private func graphLowerCamelCase(_ name: String) -> String { + let characters = Array(name) + guard let first = characters.first, first.isUppercase else { + return name + } + + let uppercaseCount = characters.prefix(while: { $0.isUppercase }).count + if uppercaseCount == characters.count { + return name.lowercased() + } + if uppercaseCount == 1 { + return String(first).lowercased() + String(characters.dropFirst()) + } + if !characters[uppercaseCount].isLowercase { + let prefix = String(characters.prefix(uppercaseCount)).lowercased() + return prefix + String(characters.dropFirst(uppercaseCount)) + } + + let prefix = String(characters.prefix(uppercaseCount - 1)).lowercased() + return prefix + String(characters.dropFirst(uppercaseCount - 1)) +} diff --git a/Sources/CradleGraphAnalysis/GraphDiagram.swift b/Sources/CradleGraphAnalysis/GraphDiagram.swift new file mode 100644 index 0000000..8620332 --- /dev/null +++ b/Sources/CradleGraphAnalysis/GraphDiagram.swift @@ -0,0 +1,44 @@ +// +// GraphDiagram.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/4/26. +// + +// Swift source에서 수집한 DependencyGraph Mermaid 표현 정보 +package struct GraphDiagram: Hashable { + // 모듈 안에서 graph를 구별하는 원본 lexical type 경로 + package let lexicalName: String + // 충돌과 오류 메시지에 사용할 선언 시작 위치 + package let sourceOffset: Int + // graph가 보유하는 source graph + package let sources: [GraphDiagramSource] + // graph가 제공하는 Factory + package let providers: [GraphDiagramProvider] +} + +// graph 생성 경로가 보유한 source graph 정보 +package struct GraphDiagramSource: Hashable { + // graph 저장 프로퍼티 이름 + package let name: String + // source graph의 원본 타입 표기 + package let typeName: String + // source graph 선언 연결용 정규 타입 철자 + package let identity: GraphTypeIdentity +} + +// graph 안의 provider Factory 표현 정보 +package struct GraphDiagramProvider: Hashable { + // Factory 선언 이름 + package let factoryName: String + // Factory 반환 타입의 원본 표기 + package let typeName: String + // provider 연결용 정규 반환 타입 철자 + package let identity: GraphTypeIdentity + // graph가 Factory 결과를 소유하는 방식 + package let lifetime: GraphProviderLifetime + // `@External`을 제외한 Factory 매개변수 타입 + package let dependencyIdentities: [GraphTypeIdentity] + // Factory 본문이 실제로 읽는 source graph 저장 프로퍼티 + package let sourceNames: Set +} diff --git a/Sources/CradleGraphAnalysis/GraphDiagramParser.swift b/Sources/CradleGraphAnalysis/GraphDiagramParser.swift new file mode 100644 index 0000000..525a6c3 --- /dev/null +++ b/Sources/CradleGraphAnalysis/GraphDiagramParser.swift @@ -0,0 +1,336 @@ +// +// GraphDiagramParser.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntax +import SwiftSyntaxBuilder + +// 모든 조건부 컴파일 절을 포함해 DependencyGraph Mermaid 모델 수집 +package func graphDiagrams(in sourceFile: SourceFileSyntax) -> [GraphDiagram] { + graphDiagramCollection(in: sourceFile).diagrams +} + +// 제외 graph의 이름도 보존해 다른 범위의 동명 graph에 잘못 연결하지 않도록 수집 +package func graphDiagramCollection(in sourceFile: SourceFileSyntax) -> ( + diagrams: [GraphDiagram], excludedNames: Set +) { + let collector = GraphDiagramCollector() + collector.walk(sourceFile) + return (collector.diagrams, collector.excludedNames) +} + +// source file의 lexical type 경로를 유지하는 graph 수집기 +private final class GraphDiagramCollector: SyntaxVisitor { + // 현재 선언 안쪽의 type 이름 경로 + private var typePath = [String]() + // 수집한 graph를 source 선언 순서로 보관 + private(set) var diagrams = [GraphDiagram]() + // 내부를 분석하지 않는 graph의 lexical 이름 + private(set) var excludedNames = Set() + + // sourceAccurate traversal로 모든 `#if` 절을 함께 방문 + init() { + super.init(viewMode: .sourceAccurate) + } + + // class graph 수집과 중첩 type 경로 관리 + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + collectGraphIfNeeded( + attributes: node.attributes, + name: node.name, + memberBlock: node.memberBlock + ) + typePath.append(graphIdentifierName(node.name)) + return .visitChildren + } + + // class type 경로 종료 + override func visitPost(_ node: ClassDeclSyntax) { + typePath.removeLast() + } + + // actor graph 수집과 중첩 type 경로 관리 + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + collectGraphIfNeeded( + attributes: node.attributes, + name: node.name, + memberBlock: node.memberBlock + ) + typePath.append(graphIdentifierName(node.name)) + return .visitChildren + } + + // actor type 경로 종료 + override func visitPost(_ node: ActorDeclSyntax) { + typePath.removeLast() + } + + // struct type 경로 관리 + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + typePath.append(graphIdentifierName(node.name)) + return .visitChildren + } + + // struct type 경로 종료 + override func visitPost(_ node: StructDeclSyntax) { + typePath.removeLast() + } + + // enum type 경로 관리 + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + typePath.append(graphIdentifierName(node.name)) + return .visitChildren + } + + // enum type 경로 종료 + override func visitPost(_ node: EnumDeclSyntax) { + typePath.removeLast() + } + + // extension 대상 type 경로 안쪽 graph의 lexical 이름 보존 + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + typePath.append(node.extendedType.trimmedDescription) + return .visitChildren + } + + // extension 대상 type 경로 종료 + override func visitPost(_ node: ExtensionDeclSyntax) { + typePath.removeLast() + } + + // `@DependencyGraph` class·actor의 산출물 제외 표식과 provider를 수집 + private func collectGraphIfNeeded( + attributes: AttributeListSyntax, + name: TokenSyntax, + memberBlock: MemberBlockSyntax + ) { + guard let attribute = graphAttribute(in: attributes) else { + return + } + let lexicalName = (typePath + [graphIdentifierName(name)]).joined(separator: ".") + guard graphDiagramIsEnabled(in: attribute) else { + excludedNames.insert(lexicalName) + return + } + let sources = graphDiagramSources(in: attribute) + let providers = graphDiagramProviders( + in: memberBlock, + sourceNames: Set(sources.map(\.name)) + ) + diagrams.append( + GraphDiagram( + lexicalName: lexicalName, + sourceOffset: graphSourceOffset(of: name), + sources: sources, + providers: providers + ) + ) + } +} + +// graph 본체의 직접 `@Provide` Factory만 수집 +private final class GraphDiagramProviderCollector: SyntaxVisitor { + // source 참조 분석에 사용할 graph 저장 프로퍼티 이름 + private let sourceNames: Set + // 중첩 type·function 안쪽 선언 제외 깊이 + private var nestedDepth = 0 + // graph 직접 member인 provider + private(set) var providers = [GraphDiagramProvider]() + + init(sourceNames: Set) { + self.sourceNames = sourceNames + super.init(viewMode: .sourceAccurate) + } + + // 중첩 class 안쪽 Factory 제외 + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + nestedDepth += 1 + return .visitChildren + } + + // 중첩 class 깊이 종료 + override func visitPost(_ node: ClassDeclSyntax) { + nestedDepth -= 1 + } + + // 중첩 actor 안쪽 Factory 제외 + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + nestedDepth += 1 + return .visitChildren + } + + // 중첩 actor 깊이 종료 + override func visitPost(_ node: ActorDeclSyntax) { + nestedDepth -= 1 + } + + // 중첩 struct 안쪽 Factory 제외 + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + nestedDepth += 1 + return .visitChildren + } + + // 중첩 struct 깊이 종료 + override func visitPost(_ node: StructDeclSyntax) { + nestedDepth -= 1 + } + + // 중첩 enum 안쪽 Factory 제외 + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + nestedDepth += 1 + return .visitChildren + } + + // 중첩 enum 깊이 종료 + override func visitPost(_ node: EnumDeclSyntax) { + nestedDepth -= 1 + } + + // graph 직접 member의 `@Provide` Factory 수집 + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + guard nestedDepth == 0, + let attribute = provideAttribute(in: node.attributes), + let returnType = node.signature.returnClause?.type, + !node.signature.parameterClause.parameters.contains(where: { parameter in + graphExternalAttribute(in: parameter.attributes) != nil + }) else { + return .skipChildren + } + let dependencies = node.signature.parameterClause.parameters.map(\.type).map(graphTypeIdentity) + let references = graphSourceReferences(in: node, sourceNames: sourceNames) + providers.append( + GraphDiagramProvider( + factoryName: graphIdentifierName(node.name), + typeName: returnType.trimmedDescription, + identity: graphTypeIdentity(for: returnType), + lifetime: graphProviderLifetime(in: attribute), + dependencyIdentities: dependencies, + sourceNames: references.sourceNames + ) + ) + return .skipChildren + } + + // 저장 프로퍼티 초기화식과 accessor 안쪽의 지역 Factory 제외 + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + // initializer와 subscript 안쪽의 지역 Factory 제외 + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } +} + +// `@DependencyGraph` attribute 반환 +private func graphAttribute(in attributes: AttributeListSyntax) -> AttributeSyntax? { + attributes.compactMap { element in + element.as(AttributeSyntax.self) + }.first { attribute in + if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) { + return graphIdentifierName(identifier.name) == "DependencyGraph" + } + guard let member = attribute.attributeName.as(MemberTypeSyntax.self), + graphIdentifierName(member.name) == "DependencyGraph", + let module = member.baseType.as(IdentifierTypeSyntax.self) else { + return false + } + return graphIdentifierName(module.name) == "Cradle" + } +} + +// `@Provide` attribute 반환 +private func provideAttribute(in attributes: AttributeListSyntax) -> AttributeSyntax? { + attributes.compactMap { element in + element.as(AttributeSyntax.self) + }.first { attribute in + attribute.attributeName.trimmedDescription == "Provide" + } +} + +// `diagram: false`만 Mermaid 산출물 대상에서 제외 +private func graphDiagramIsEnabled(in attribute: AttributeSyntax) -> Bool { + guard case let .argumentList(arguments)? = attribute.arguments, + let argument = arguments.first(where: { argument in + argument.label?.identifier?.name == "diagram" + }), + let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else { + return true + } + return literal.literal.text != "false" +} + +// graph `sources` argument를 source 저장 프로퍼티 정보로 변환 +private func graphDiagramSources(in attribute: AttributeSyntax) -> [GraphDiagramSource] { + guard case let .argumentList(arguments)? = attribute.arguments, + let argument = arguments.first(where: { argument in + argument.label?.identifier?.name == "sources" + }), + let array = argument.expression.as(ArrayExprSyntax.self) else { + return [] + } + return array.elements.compactMap { element in + guard let member = element.expression.as(MemberAccessExprSyntax.self), + member.declName.baseName.text == "self", + member.declName.argumentNames == nil, + let base = member.base else { + return nil + } + let type = TypeSyntax(stringLiteral: base.trimmedDescription) + guard !type.hasError, + let name = graphAccessorName(for: type), + graphHasValidAccessorName(name) else { + return nil + } + return GraphDiagramSource( + name: name, + typeName: type.trimmedDescription, + identity: graphTypeIdentity(for: type) + ) + } +} + +// graph 본체의 모든 조건부 컴파일 절에서 직접 provider 수집 +private func graphDiagramProviders( + in memberBlock: MemberBlockSyntax, + sourceNames: Set +) -> [GraphDiagramProvider] { + let collector = GraphDiagramProviderCollector(sourceNames: sourceNames) + collector.walk(memberBlock) + return collector.providers +} + +// `@Provide` 수명 인자에서 Mermaid 표현용 수명 반환 +private func graphProviderLifetime(in attribute: AttributeSyntax) -> GraphProviderLifetime { + guard case let .argumentList(arguments)? = attribute.arguments, + arguments.count == 1, + let argument = arguments.first, + argument.label == nil, + let member = argument.expression.as(MemberAccessExprSyntax.self), + member.base == nil, + graphIdentifierName(member.declName.baseName) == "transient" else { + return .shared + } + return .transient +} + +// `@External` 또는 `@Cradle.External` marker 확인 +private func graphExternalAttribute(in attributes: AttributeListSyntax) -> AttributeSyntax? { + attributes.compactMap { element in + element.as(AttributeSyntax.self) + }.first { attribute in + if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) { + return graphIdentifierName(identifier.name) == "External" + } + guard let member = attribute.attributeName.as(MemberTypeSyntax.self), + graphIdentifierName(member.name) == "External", + let module = member.baseType.as(IdentifierTypeSyntax.self) else { + return false + } + return graphIdentifierName(module.name) == "Cradle" + } +} + +// 백틱을 제외한 식별자 문자열 반환 +private func graphIdentifierName(_ token: TokenSyntax) -> String { + token.identifier?.name ?? token.text +} diff --git a/Sources/CradleGraphAnalysis/GraphSourceReference.swift b/Sources/CradleGraphAnalysis/GraphSourceReference.swift new file mode 100644 index 0000000..70ef036 --- /dev/null +++ b/Sources/CradleGraphAnalysis/GraphSourceReference.swift @@ -0,0 +1,388 @@ +// +// GraphSourceReference.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/2/26. +// + +import SwiftSyntax + +// Factory 본문에 나타난 source 저장 프로퍼티 참조 위치 집합 +package struct GraphSourceReferences { + // bare source 저장 프로퍼티 참조 위치와 이름 + package let bare: [Int: String] + // `self.source` 저장 프로퍼티 참조 위치와 이름 + package let explicitSelf: [Int: String] + // bare closure capture source 저장 프로퍼티 참조 위치와 이름 + package let bareCapture: [Int: String] + + // Factory가 실제로 읽는 source 저장 프로퍼티 이름 집합 + package var sourceNames: Set { + Set(bare.values) + .union(explicitSelf.values) + .union(bareCapture.values) + } +} + +// lexical scope를 반영해 Factory의 source 저장 프로퍼티 참조 수집 +package func graphSourceReferences( + in factory: FunctionDeclSyntax, + sourceNames: Set +) -> GraphSourceReferences { + guard let body = factory.body else { + return GraphSourceReferences(bare: [:], explicitSelf: [:], bareCapture: [:]) + } + let finder = SourceGraphReferenceFinder( + sourceNames: sourceNames, + parameterNames: Set(factory.signature.parameterClause.parameters.map { parameter in + let name = parameter.secondName ?? parameter.firstName + return name.identifier?.name ?? name.text + }) + ) + finder.walk(body) + return finder.references +} + +// lexical scope를 반영한 source 저장 프로퍼티 참조 탐색기 +private final class SourceGraphReferenceFinder: SyntaxVisitor { + // 생성된 source 저장 프로퍼티 이름 집합 + private let sourceNames: Set + // 현재 lexical scope의 shadowing 이름 집합 + private var scopes: [Set] + // 수집한 source 저장 프로퍼티 참조 위치 + private var bare = [Int: String]() + private var explicitSelf = [Int: String]() + private var bareCapture = [Int: String]() + + // 수집한 source 저장 프로퍼티 참조 + var references: GraphSourceReferences { + GraphSourceReferences(bare: bare, explicitSelf: explicitSelf, bareCapture: bareCapture) + } + + // source 이름과 Factory 매개변수 이름으로 탐색기 생성 + init(sourceNames: Set, parameterNames: Set) { + self.sourceNames = sourceNames + scopes = [parameterNames] + super.init(viewMode: .sourceAccurate) + } + + // code block마다 지역 변수 shadowing scope 추가 + override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind { + scopes.append(sourceGraphLocalFunctionNames(in: node.statements)) + return .visitChildren + } + + // code block 종료 후 지역 변수 shadowing scope 제거 + override func visitPost(_ node: CodeBlockSyntax) { + scopes.removeLast() + } + + // 선언 순서대로 initializer를 읽고 지역 변수 이름을 현재 scope에 추가 + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + walk(node.attributes) + for binding in node.bindings { + if let initializer = binding.initializer { + walk(initializer.value) + } + insert(sourceGraphBoundNames(in: binding.pattern)) + if let accessorBlock = binding.accessorBlock { + walk(accessorBlock) + } + } + return .skipChildren + } + + // closure capture·매개변수 shadowing scope 추가 + override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + if let capture = node.signature?.capture { + walkSourceGraphCapture(capture) + } + let names = sourceGraphClosureCaptureNames(in: node) + .union(sourceGraphClosureParameterNames(in: node)) + .union(sourceGraphLocalFunctionNames(in: node.statements)) + scopes.append(names) + walk(node.statements) + scopes.removeLast() + return .skipChildren + } + + // if 조건 binding의 initializer와 본문 scope 분리 탐색 + override func visit(_ node: IfExprSyntax) -> SyntaxVisitorContinueKind { + scopes.append([]) + for element in node.conditions { + walkSourceGraphCondition(element.condition) + insert(sourceGraphBoundNames(in: element.condition)) + } + walk(node.body) + scopes.removeLast() + if let elseBody = node.elseBody { + walk(elseBody) + } + return .skipChildren + } + + // while 조건 binding의 initializer와 본문 scope 분리 탐색 + override func visit(_ node: WhileStmtSyntax) -> SyntaxVisitorContinueKind { + scopes.append([]) + for element in node.conditions { + walkSourceGraphCondition(element.condition) + insert(sourceGraphBoundNames(in: element.condition)) + } + walk(node.body) + scopes.removeLast() + return .skipChildren + } + + // guard 조건 binding을 후속 조건과 성공 경로에만 적용 + override func visit(_ node: GuardStmtSyntax) -> SyntaxVisitorContinueKind { + scopes.append([]) + for element in node.conditions { + walkSourceGraphCondition(element.condition) + insert(sourceGraphBoundNames(in: element.condition)) + } + scopes.removeLast() + walk(node.body) + for element in node.conditions { + insert(sourceGraphBoundNames(in: element.condition)) + } + return .skipChildren + } + + // for pattern binding을 sequence 다음 body와 where clause에만 적용 + override func visit(_ node: ForStmtSyntax) -> SyntaxVisitorContinueKind { + walk(node.sequence) + scopes.append(sourceGraphBoundNames(in: node.pattern)) + if let whereClause = node.whereClause { + walk(whereClause) + } + walk(node.body) + scopes.removeLast() + return .skipChildren + } + + // catch pattern binding을 where clause와 catch 본문에만 적용 + override func visit(_ node: CatchClauseSyntax) -> SyntaxVisitorContinueKind { + scopes.append(sourceGraphCatchClauseNames(in: node)) + walk(node.catchItems) + walk(node.body) + scopes.removeLast() + return .skipChildren + } + + // 중첩 함수 매개변수 scope와 선언 이름 적용 + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + for parameter in node.signature.parameterClause.parameters { + if let defaultValue = parameter.defaultValue { + walk(defaultValue.value) + } + } + let names = sourceGraphFunctionParameterNames(in: node) + .union([sourceGraphIdentifierName(node.name)]) + scopes.append(names) + if let body = node.body { + walk(body) + } + scopes.removeLast() + insert([sourceGraphIdentifierName(node.name)]) + return .skipChildren + } + + // 중첩 타입 선언은 바깥 graph source 탐색에서 제외 + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren } + + // switch case pattern binding을 where clause와 case 본문에만 적용 + override func visit(_ node: SwitchCaseSyntax) -> SyntaxVisitorContinueKind { + scopes.append(sourceGraphSwitchCaseNames(in: node)) + walk(node.label) + walk(node.statements) + scopes.removeLast() + return .skipChildren + } + + // shadowing되지 않은 bare source 저장 프로퍼티 참조 기록 + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { + recordSourceGraphReference(node.baseName, kind: .bare) + return .skipChildren + } + + // 명시적 self의 source 저장 프로퍼티 참조 기록 + override func visit(_ node: MemberAccessExprSyntax) -> SyntaxVisitorContinueKind { + guard let base = node.base?.as(DeclReferenceExprSyntax.self), + base.baseName.tokenKind == .keyword(.self) else { + return .visitChildren + } + recordSourceGraphReference(node.declName.baseName, kind: .explicitSelf, ignoresShadowing: true) + return .skipChildren + } + + // source 참조 기록 방식 + private enum ReferenceKind { + case bare + case explicitSelf + case bareCapture + } + + // 현재 가장 안쪽 scope에 새 binding 이름 추가 + private func insert(_ names: Set) { + guard var scope = scopes.popLast() else { + return + } + scope.formUnion(names) + scopes.append(scope) + } + + // if 조건별 initializer를 먼저 읽는 탐색 + private func walkSourceGraphCondition(_ condition: ConditionElementSyntax.Condition) { + switch condition { + case let .expression(expression): + walk(expression) + case let .availability(availability): + walk(availability) + case let .matchingPattern(matching): + walk(matching) + case let .optionalBinding(binding): + walk(binding) + } + } + + // closure capture 초기화식과 bare capture source 참조 탐색 + private func walkSourceGraphCapture(_ capture: ClosureCaptureClauseSyntax) { + for item in capture.items { + if let initializer = item.initializer { + walk(initializer) + } else { + recordSourceGraphReference(item.name, kind: .bareCapture) + } + } + } + + // shadowing되지 않은 source 저장 프로퍼티 참조 기록 + private func recordSourceGraphReference( + _ token: TokenSyntax, + kind: ReferenceKind, + ignoresShadowing: Bool = false + ) { + let name = sourceGraphIdentifierName(token) + guard sourceNames.contains(name) else { + return + } + guard ignoresShadowing || !scopes.contains(where: { $0.contains(name) }) else { + return + } + switch kind { + case .bare: + bare[graphSourceOffset(of: token)] = name + case .explicitSelf: + explicitSelf[graphSourceOffset(of: token)] = name + case .bareCapture: + bareCapture[graphSourceOffset(of: token)] = name + } + } +} + +// SwiftSyntax token의 source file UTF-8 위치 +package func graphSourceOffset(of token: TokenSyntax) -> Int { + token.positionAfterSkippingLeadingTrivia.utf8Offset +} + +// pattern에서 선언하는 이름 수집기 +private final class SourceGraphPatternNameFinder: SyntaxVisitor { + // pattern이 선언하는 지역 이름 집합 + private(set) var names = Set() + + // identifier pattern의 선언 이름 수집 + override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind { + names.insert(sourceGraphIdentifierName(node.identifier)) + return .skipChildren + } +} + +// pattern의 모든 지역 binding 이름 수집 +private func sourceGraphBoundNames(in pattern: PatternSyntax) -> Set { + let finder = SourceGraphPatternNameFinder(viewMode: .sourceAccurate) + finder.walk(pattern) + return finder.names +} + +// 조건 binding이 선언하는 이름 수집 +private func sourceGraphBoundNames(in condition: ConditionElementSyntax.Condition) -> Set { + switch condition { + case let .matchingPattern(matching): + sourceGraphBoundNames(in: matching.pattern) + case let .optionalBinding(binding): + sourceGraphBoundNames(in: binding.pattern) + case .expression, .availability: + [] + } +} + +// closure 매개변수의 지역 이름 수집 +private func sourceGraphClosureParameterNames(in closure: ClosureExprSyntax) -> Set { + guard let parameterClause = closure.signature?.parameterClause else { + return [] + } + switch parameterClause { + case let .simpleInput(parameters): + return Set(parameters.map { sourceGraphIdentifierName($0.name) }) + case let .parameterClause(parameters): + return Set(parameters.parameters.map { parameter in + sourceGraphIdentifierName(parameter.secondName ?? parameter.firstName) + }) + } +} + +// closure capture가 closure 본문에 선언하는 이름 수집 +private func sourceGraphClosureCaptureNames(in closure: ClosureExprSyntax) -> Set { + guard let capture = closure.signature?.capture else { + return [] + } + return Set(capture.items.map { sourceGraphIdentifierName($0.name) }) +} + +// switch case가 선언하는 지역 이름 수집 +private func sourceGraphSwitchCaseNames(in switchCase: SwitchCaseSyntax) -> Set { + guard case let .case(label) = switchCase.label else { + return [] + } + return label.caseItems.reduce(into: Set()) { names, item in + names.formUnion(sourceGraphBoundNames(in: item.pattern)) + } +} + +// catch clause가 선언하는 지역 이름 수집 +private func sourceGraphCatchClauseNames(in clause: CatchClauseSyntax) -> Set { + clause.catchItems.reduce(into: Set()) { names, item in + guard let pattern = item.pattern else { + return + } + names.formUnion(sourceGraphBoundNames(in: pattern)) + } +} + +// 중첩 함수 매개변수의 지역 이름 수집 +private func sourceGraphFunctionParameterNames(in function: FunctionDeclSyntax) -> Set { + Set(function.signature.parameterClause.parameters.map { parameter in + let name = parameter.secondName ?? parameter.firstName + return sourceGraphIdentifierName(name) + }) +} + +// code block의 직접 자식 지역 함수 이름 수집 +private func sourceGraphLocalFunctionNames(in statements: CodeBlockItemListSyntax) -> Set { + statements.reduce(into: Set()) { names, statement in + guard let function = statement.item.as(FunctionDeclSyntax.self) else { + return + } + names.insert(sourceGraphIdentifierName(function.name)) + } +} + +// backtick 표기를 제외한 identifier 이름 읽기 +private func sourceGraphIdentifierName(_ token: TokenSyntax) -> String { + token.identifier?.name ?? token.text +} diff --git a/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift b/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift new file mode 100644 index 0000000..f82d287 --- /dev/null +++ b/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift @@ -0,0 +1,54 @@ +// +// GraphTypeIdentity.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntax + +// graph 등록과 source 선언을 비교하는 정규 타입 철자 +package struct GraphTypeIdentity: Hashable { + // 의미 분석 없이 정규화한 타입 철자 + package let canonicalText: String +} + +// Mermaid node 수명 표기에 사용할 provider 수명 +package enum GraphProviderLifetime: String { + // 프로퍼티 접근마다 Factory를 호출하는 수명 + case transient + // graph 생성 중 한 번 만들고 보관하는 수명 + case shared +} + +// 타입 연결과 정렬에 사용할 정규 identity 생성 +package func graphTypeIdentity(for type: TypeSyntax) -> GraphTypeIdentity { + let normalized = unwrappedGraphIdentityType(type) + let text = normalized.tokens(viewMode: .sourceAccurate).map { token in + token.identifier?.name ?? token.text + }.joined() + return GraphTypeIdentity(canonicalText: text) +} + +// 바깥 괄호와 최상위 any를 제거한 identity 비교 타입 +private func unwrappedGraphIdentityType(_ type: TypeSyntax) -> TypeSyntax { + let type = unwrappedGraphParenthesizedType(type) + guard let existential = type.as(SomeOrAnyTypeSyntax.self), + existential.someOrAnySpecifier.tokenKind == .keyword(.any) else { + return type + } + return unwrappedGraphParenthesizedType(existential.constraint) +} + +// identity 비교에서 의미를 바꾸지 않는 단일 바깥 괄호 제거 +private func unwrappedGraphParenthesizedType(_ type: TypeSyntax) -> TypeSyntax { + guard let tuple = type.as(TupleTypeSyntax.self), + tuple.elements.count == 1, + let element = tuple.elements.first, + element.firstName == nil, + element.secondName == nil, + element.ellipsis == nil else { + return type + } + return unwrappedGraphParenthesizedType(element.type) +} diff --git a/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift b/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift new file mode 100644 index 0000000..a92da00 --- /dev/null +++ b/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift @@ -0,0 +1,162 @@ +// +// MermaidDiagramRenderer.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/4/26. +// + +import Foundation + +// target의 모든 graph와 source 연결을 하나의 Mermaid 그림으로 변환 +package func mermaidDiagram(for diagrams: [GraphDiagram], excludedNames: Set = []) -> String { + let target = TargetGraphDiagram(diagrams: diagrams, excludedNames: excludedNames) + var lines = [ + "%% CradlePlugin이 생성한 의존성 graph", + "%% 모든 조건부 컴파일 절을 포함하며 실제 활성 build condition을 뜻하지 않음", + "%% plugin work directory 산출물이므로 swift package clean 뒤 사라질 수 있음", + "flowchart TB", + " classDef source stroke:#333,stroke-width:1px;", + " classDef shared stroke:#333,stroke-width:2px;", + " classDef transient stroke:#333,stroke-width:2px,stroke-dasharray:5 5;" + ] + for (index, diagram) in target.diagrams.enumerated() { + lines += mermaidGraph(diagram, prefix: "graph\(index)", target: target) + } + return lines.joined(separator: "\n") + "\n" +} + +// graph별 node ID를 분리하고 해석된 source는 공용 anchor에 연결 +private func mermaidGraph(_ diagram: GraphDiagram, prefix: String, target: TargetGraphDiagram) -> [String] { + let sources = diagram.sources.sorted(by: graphDiagramSourceOrder) + let providers = diagram.providers.sorted(by: graphDiagramProviderOrder) + let sourceIDs = Dictionary(uniqueKeysWithValues: sources.indices.map { index in + (sources[index].name, target.anchor(for: sources[index], in: diagram) ?? "\(prefix)_source\(index)") + }) + let providerIDs = providers.indices.map { "\(prefix)_provider\($0)" } + let providersByIdentity = Dictionary(grouping: providers.enumerated(), by: { _, provider in + provider.identity + }) + + var lines = [ + " subgraph \(prefix)[\" \"]", + " \(prefix)_root[\"\(mermaidLabel(diagram.lexicalName))\"]" + ] + lines += sources.enumerated().compactMap { index, source in + guard target.anchor(for: source, in: diagram) == nil else { return nil } + return " \(prefix)_source\(index)[\"\(mermaidLabel(source.typeName))\"]" + } + lines += providers.enumerated().map { index, provider in + " \(providerIDs[index])[\"\(mermaidProviderLabel(provider))\"]" + } + lines.append(" end") + lines.append(" class \(prefix)_root source") + lines += Set(sourceIDs.values).sorted().map { " \(prefix)_root --> \($0)" } + lines += mermaidEdges( + providers: providers, + providerIDs: providerIDs, + providersByIdentity: providersByIdentity, + sourceIDs: sourceIDs + ) + lines += mermaidNodeStyles( + sources: sources, + providers: providers, + sourceIDs: sourceIDs, + providerIDs: providerIDs + ) + return lines +} + +// source graph의 안정적인 Mermaid node 순서 +private func graphDiagramSourceOrder(_ lhs: GraphDiagramSource, _ rhs: GraphDiagramSource) -> Bool { + if lhs.identity.canonicalText != rhs.identity.canonicalText { + return lhs.identity.canonicalText < rhs.identity.canonicalText + } + if lhs.name != rhs.name { + return lhs.name < rhs.name + } + return lhs.typeName < rhs.typeName +} + +// provider의 안정적인 Mermaid node 순서 +private func graphDiagramProviderOrder(_ lhs: GraphDiagramProvider, _ rhs: GraphDiagramProvider) -> Bool { + if lhs.identity.canonicalText != rhs.identity.canonicalText { + return lhs.identity.canonicalText < rhs.identity.canonicalText + } + if lhs.factoryName != rhs.factoryName { + return lhs.factoryName < rhs.factoryName + } + if lhs.typeName != rhs.typeName { + return lhs.typeName < rhs.typeName + } + if lhs.lifetime != rhs.lifetime { + return lhs.lifetime.rawValue < rhs.lifetime.rawValue + } + // 간선에 사용되는 집합을 정렬해 조건부 동명 Factory의 순서 고정 + let leftDependencies = Set(lhs.dependencyIdentities.map(\.canonicalText)).sorted() + let rightDependencies = Set(rhs.dependencyIdentities.map(\.canonicalText)).sorted() + if leftDependencies != rightDependencies { + return leftDependencies.lexicographicallyPrecedes(rightDependencies) + } + return lhs.sourceNames.sorted().lexicographicallyPrecedes(rhs.sourceNames.sorted()) +} + +// provider의 type 의존성과 source 참조를 모두 실선 화살표로 반환 +private func mermaidEdges( + providers: [GraphDiagramProvider], + providerIDs: [String], + providersByIdentity: [GraphTypeIdentity: [(offset: Int, element: GraphDiagramProvider)]], + sourceIDs: [String: String] +) -> [String] { + var edges = Set() + for (index, provider) in providers.enumerated() { + for identity in provider.dependencyIdentities { + for dependency in providersByIdentity[identity] ?? [] { + edges.insert(" \(providerIDs[index]) --> \(providerIDs[dependency.offset])") + } + } + for sourceName in provider.sourceNames.sorted() { + guard let sourceID = sourceIDs[sourceName] else { + continue + } + edges.insert(" \(providerIDs[index]) --> \(sourceID)") + } + } + return edges.sorted() +} + +// lifetime과 source graph 경계를 Mermaid class로 구분 +private func mermaidNodeStyles( + sources: [GraphDiagramSource], + providers: [GraphDiagramProvider], + sourceIDs: [String: String], + providerIDs: [String] +) -> [String] { + var lines = [String]() + for source in sources { + guard let sourceID = sourceIDs[source.name] else { + continue + } + lines.append(" class \(sourceID) source") + } + for (index, provider) in providers.enumerated() { + lines.append(" class \(providerIDs[index]) \(provider.lifetime.rawValue)") + } + return lines +} + +// provider node에서 Factory와 수명을 함께 표시 +private func mermaidProviderLabel(_ provider: GraphDiagramProvider) -> String { + let lifetime = ".\(provider.lifetime.rawValue)" + return [provider.typeName, provider.factoryName, lifetime] + .map(mermaidLabel) + .joined(separator: "
") +} + +// Mermaid 대괄호 label에서 의미가 달라지는 문자를 이스케이프 +private func mermaidLabel(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) +} diff --git a/Sources/CradleGraphAnalysis/TargetGraphDiagram.swift b/Sources/CradleGraphAnalysis/TargetGraphDiagram.swift new file mode 100644 index 0000000..85be09f --- /dev/null +++ b/Sources/CradleGraphAnalysis/TargetGraphDiagram.swift @@ -0,0 +1,44 @@ +// +// TargetGraphDiagram.swift +// CradleGraphAnalysis +// +// Created by opfic on 9/5/26. +// + +// target의 graph를 한 번씩 정렬하고 source 선언을 연결하는 집합 +package struct TargetGraphDiagram { + // 파일·선언 순서와 무관한 graph 순서 + package let diagrams: [GraphDiagram] + // 정확한 lexical 경로에 대응하는 graph anchor + private let anchors: [String: String] + // 제외 graph가 바깥 범위의 동명 graph를 가리는 경로 + private let excludedNames: Set + + // 중복 lexical 경로 검증을 마친 분석 모델로 집합 구성 + package init(diagrams: [GraphDiagram], excludedNames: Set = []) { + self.diagrams = diagrams.sorted { $0.lexicalName < $1.lexicalName } + // 다른 조건부 절에 활성 선언이 있으면 동일 경로의 graph는 포함 + self.excludedNames = excludedNames.subtracting(diagrams.map(\.lexicalName)) + anchors = Dictionary(uniqueKeysWithValues: self.diagrams.enumerated().map { index, diagram in + (diagram.lexicalName, "graph\(index)_root") + }) + } + + // 의미 분석 없이 바깥 lexical scope의 정확한 source 이름만 연결 + package func anchor(for source: GraphDiagramSource, in diagram: GraphDiagram) -> String? { + let name = source.identity.canonicalText + if name.contains(".") { + return excludedNames.contains(name) ? nil : anchors[name] + } + var scope = diagram.lexicalName.split(separator: ".").dropLast().map(String.init) + while !scope.isEmpty { + let candidate = (scope + [name]).joined(separator: ".") + if excludedNames.contains(candidate) { return nil } + if let anchor = anchors[candidate] { + return anchor + } + scope.removeLast() + } + return excludedNames.contains(name) ? nil : anchors[name] + } +} diff --git a/Sources/CradleMacros/DependencyGraphMacro.swift b/Sources/CradleMacros/DependencyGraphMacro.swift index 5355372..1d6575a 100644 --- a/Sources/CradleMacros/DependencyGraphMacro.swift +++ b/Sources/CradleMacros/DependencyGraphMacro.swift @@ -28,6 +28,9 @@ struct DependencyGraphMacro: MemberMacro { guard let overrideConfiguration = typedOverrideConfiguration(from: node, in: context) else { return [] } + guard diagramConfiguration(from: node, in: context) != nil else { + return [] + } let sourceResult = sourceGraphResult(from: node, in: context) guard let sources = acceptedSourceDescriptors(for: graph, from: node, result: sourceResult, in: context) else { diff --git a/Sources/CradleMacros/DiagramConfiguration.swift b/Sources/CradleMacros/DiagramConfiguration.swift new file mode 100644 index 0000000..c36c855 --- /dev/null +++ b/Sources/CradleMacros/DiagramConfiguration.swift @@ -0,0 +1,42 @@ +// +// DiagramConfiguration.swift +// CradleMacros +// +// Created by opfic on 9/4/26. +// + +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +// `@DependencyGraph`의 Mermaid 산출물 제외 표식 +struct DiagramConfiguration { + // plugin 분석 대상 포함 여부 + let isEnabled: Bool +} + +// `diagram` 인자의 직접 작성 Bool literal 검증 +func diagramConfiguration( + from attribute: AttributeSyntax, + in context: some MacroExpansionContext +) -> DiagramConfiguration? { + guard case let .argumentList(arguments)? = attribute.arguments else { + return DiagramConfiguration(isEnabled: true) + } + let supplied = arguments.filter { argument in + argument.label?.identifier?.name == "diagram" + } + guard supplied.count <= 1 else { + context.diagnose(Diagnostic(node: arguments, message: DiagramDiagnostic.invalidConfiguration)) + return nil + } + guard let argument = supplied.first else { + return DiagramConfiguration(isEnabled: true) + } + guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self), + !argument.expression.hasError else { + context.diagnose(Diagnostic(node: argument.expression, message: DiagramDiagnostic.invalidConfiguration)) + return nil + } + return DiagramConfiguration(isEnabled: literal.literal.text == "true") +} diff --git a/Sources/CradleMacros/DiagramDiagnostic.swift b/Sources/CradleMacros/DiagramDiagnostic.swift new file mode 100644 index 0000000..aca84bd --- /dev/null +++ b/Sources/CradleMacros/DiagramDiagnostic.swift @@ -0,0 +1,33 @@ +// +// DiagramDiagnostic.swift +// CradleMacros +// +// Created by opfic on 9/4/26. +// + +import SwiftDiagnostics + +// Mermaid 산출물 제외 표식의 구문 진단 +enum DiagramDiagnostic: DiagnosticMessage { + // `diagram` 인자의 정적 Bool literal 제약 + case invalidConfiguration + + // 경우별 고정 진단 식별자 + var diagnosticID: MessageID { + switch self { + case .invalidConfiguration: + MessageID(domain: "Cradle", id: "invalidDiagramConfiguration") + } + } + + // 경우별 사용자 오류 설명 + var message: String { + switch self { + case .invalidConfiguration: + "`diagram`은 직접 작성한 `true` 또는 `false`여야 합니다." + } + } + + // 지원하지 않는 표식은 graph 생성 중단 + var severity: DiagnosticSeverity { .error } +} diff --git a/Sources/CradleMacros/SourceGraphDescriptor.swift b/Sources/CradleMacros/SourceGraphDescriptor.swift index f257279..02f2108 100644 --- a/Sources/CradleMacros/SourceGraphDescriptor.swift +++ b/Sources/CradleMacros/SourceGraphDescriptor.swift @@ -59,7 +59,7 @@ func sourceGraphResult( guard sourceArguments.count <= 1, arguments.allSatisfy({ argument in let name = argument.label?.identifier?.name - return name == "sources" || name == "overrides" + return name == "sources" || name == "overrides" || name == "diagram" }) else { context.diagnose(Diagnostic(node: arguments, message: SourceGraphDiagnostic.invalidSources)) return SourceGraphResult(descriptors: [], hasError: true) diff --git a/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift b/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift new file mode 100644 index 0000000..6e8c1a2 --- /dev/null +++ b/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift @@ -0,0 +1,313 @@ +// +// DiagramOutputWriterTests.swift +// CradleDiagramMakerSupportTests +// +// Created by opfic on 9/4/26. +// + +import CradleDiagramMakerSupport +import Foundation +import Testing + +// 제외 graph가 가까운 범위에 있으면 바깥 동명 graph로 연결하지 않는지 검증 +@Test +func diagramOutputWriterPreservesExcludedLexicalShadowing() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("Graphs.swift") + try """ + @DependencyGraph final class SharedGraph {} + enum Scope { + @DependencyGraph(diagram: false) final class SharedGraph { + @Provide func makeSecret() -> Secret { Secret() } + } + @DependencyGraph(sources: [SharedGraph.self]) final class AppGraph {} + } + """.write(to: source, atomically: true, encoding: .utf8) + let outputs = try DiagramOutputWriter().write(request: DiagramOutputRequest( + moduleName: "App", sourceURLs: [source], outputDirectoryURL: temporary)) + let output = try #require(outputs.first) + let content = try String(contentsOf: output, encoding: .utf8) + #expect(content.contains("graph0_root --> graph0_source0")) + #expect(!content.contains("graph0_root --> graph1_root")) + #expect(!content.contains("Secret")) +} + +// 여러 source 파일을 하나로 합치고 이전 graph별 산출물만 정리하는지 검증 +@Test +func diagramOutputWriterCombinesTargetAndMigratesOldFiles() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let first = temporary.appendingPathComponent("First.swift") + let second = temporary.appendingPathComponent("Second.swift") + try "@DependencyGraph(sources: [BGraph.self]) final class AGraph {}" + .write(to: first, atomically: true, encoding: .utf8) + try "@DependencyGraph final class BGraph {}".write(to: second, atomically: true, encoding: .utf8) + let directory = temporary.appendingPathComponent("App") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let old = directory.appendingPathComponent("AGraph-old.mmd") + let note = directory.appendingPathComponent("note.txt") + try "old".write(to: old, atomically: true, encoding: .utf8) + try "keep".write(to: note, atomically: true, encoding: .utf8) + let writer = DiagramOutputWriter() + let outputs = try writer.write(request: DiagramOutputRequest( + moduleName: "App", sourceURLs: [first, second], outputDirectoryURL: temporary)) + #expect(outputs == [directory.appendingPathComponent("DependencyGraph.mmd")]) + let output = try #require(outputs.first) + let content = try String(contentsOf: output, encoding: .utf8) + #expect(content.contains("AGraph") && content.contains("BGraph")) + #expect(content.contains("subgraph graph0[\" \"]")) + #expect(!content.contains("subgraph graph0[\"AGraph\"]")) + #expect(content.contains("graph0_root --> graph1_root")) + #expect(!FileManager.default.fileExists(atPath: old.path)) + #expect(try String(contentsOf: note, encoding: .utf8) == "keep") + let date = Date(timeIntervalSinceReferenceDate: 1) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: output.path) + try writer.write(request: DiagramOutputRequest( + moduleName: "App", sourceURLs: [second, first], outputDirectoryURL: temporary)) + #expect(try String(contentsOf: output, encoding: .utf8) == content) + #expect(try FileManager.default.attributesOfItem(atPath: output.path)[.modificationDate] as? Date == date) + // 모든 graph를 제외하면 단일 산출물도 제거 + try "@DependencyGraph(diagram: false) final class AGraph {}" + .write(to: first, atomically: true, encoding: .utf8) + try "@DependencyGraph(diagram: false) final class BGraph {}" + .write(to: second, atomically: true, encoding: .utf8) + #expect(try writer.write(request: DiagramOutputRequest( + moduleName: "App", sourceURLs: [first, second], outputDirectoryURL: temporary)).isEmpty) + #expect(!FileManager.default.fileExists(atPath: output.path)) +} + +// 변경된 graph만 쓰고 이전 content의 수정 시각을 보존하는지 확인 +@Test +func diagramOutputWriterWritesStableOutputWithoutRewritingUnchangedContent() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + try """ + @DependencyGraph + final class AppGraph { + @Provide + private func makeFeature() -> Feature { Feature() } + } + """.write(to: source, atomically: true, encoding: .utf8) + let request = DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + + let outputs = try DiagramOutputWriter().write(request: request) + #expect(outputs.count == 1) + let output = try #require(outputs.first) + let originalDate = Date(timeIntervalSinceReferenceDate: 1) + try FileManager.default.setAttributes([.modificationDate: originalDate], ofItemAtPath: output.path) + let repeated = try DiagramOutputWriter().write(request: request) + let modificationDate = try #require( + FileManager.default.attributesOfItem(atPath: output.path)[.modificationDate] as? Date + ) + + #expect(repeated == [output]) + #expect(modificationDate == originalDate) + #expect(output.pathExtension == "mmd") +} + +// 제외되거나 삭제된 graph의 tool 소유 산출물을 정리하는지 확인 +@Test +func diagramOutputWriterRemovesStaleModuleOutput() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + try "@DependencyGraph(diagram: false) final class AppGraph {}" + .write(to: source, atomically: true, encoding: .utf8) + let outputDirectory = temporary.appendingPathComponent("CradleDiagrams/AppComposition") + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let stale = outputDirectory.appendingPathComponent("AppGraph-old.mmd") + try "stale".write(to: stale, atomically: true, encoding: .utf8) + + let outputs = try DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ) + + #expect(outputs.isEmpty) + #expect(!FileManager.default.fileExists(atPath: stale.path)) +} + +// 중복 lexical graph 오류가 기존 성공 산출물을 유지하는지 확인 +@Test +func diagramOutputWriterPreservesExistingOutputWhenConditionalGraphsCollide() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + let outputDirectory = temporary.appendingPathComponent("CradleDiagrams/AppComposition") + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let existing = outputDirectory.appendingPathComponent("previous.mmd") + try "previous output".write(to: existing, atomically: true, encoding: .utf8) + try """ + #if DEBUG + @DependencyGraph + final class AppGraph {} + #else + @DependencyGraph + final class AppGraph {} + #endif + """.write(to: source, atomically: true, encoding: .utf8) + + #expect(throws: DiagramOutputError.self) { + try DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ) + } + #expect(try String(contentsOf: existing, encoding: .utf8) == "previous output") +} + +// 중복 source accessor가 renderer 내부 충돌 대신 오류로 기존 산출물을 보존하는지 확인 +@Test +func diagramOutputWriterPreservesExistingOutputWhenSourceAccessorsCollide() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + let outputDirectory = temporary.appendingPathComponent("CradleDiagrams/AppComposition") + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let existing = outputDirectory.appendingPathComponent("previous.mmd") + try "previous output".write(to: existing, atomically: true, encoding: .utf8) + try """ + @DependencyGraph(sources: [SessionGraph.self, SessionGraph.self]) + final class AppGraph {} + """.write(to: source, atomically: true, encoding: .utf8) + + #expect(throws: DiagramOutputError.self) { + try DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ) + } + #expect(try String(contentsOf: existing, encoding: .utf8) == "previous output") +} + +// path 구성에 사용할 module 이름이 안전하지 않으면 기존 산출물을 유지하는지 확인 +@Test +func diagramOutputWriterPreservesExistingOutputWhenModuleNameIsInvalid() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + let outputDirectory = temporary.appendingPathComponent("CradleDiagrams/ExampleApp") + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let existing = outputDirectory.appendingPathComponent("previous.mmd") + try "previous output".write(to: existing, atomically: true, encoding: .utf8) + try "@DependencyGraph final class AppGraph {}".write(to: source, atomically: true, encoding: .utf8) + + #expect(throws: DiagramOutputError.self) { + try DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "../ExampleApp", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ) + } + #expect(try String(contentsOf: existing, encoding: .utf8) == "previous output") +} + +// 공백·한글 target 이름과 source가 없는 빌드의 오래된 파일 정리 검증 +@Test +func diagramOutputWriterAcceptsTargetDisplayNamesAndEmptySources() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + try "@DependencyGraph final class AppGraph {}".write(to: source, atomically: true, encoding: .utf8) + let writer = DiagramOutputWriter() + let outputs = try writer.write(request: DiagramOutputRequest( + moduleName: "예제 App", sourceURLs: [source], outputDirectoryURL: temporary)) + #expect(outputs.count == 1) + let removed = try writer.write(request: DiagramOutputRequest( + moduleName: "예제 App", sourceURLs: [], outputDirectoryURL: temporary)) + #expect(removed.isEmpty) + #expect(!FileManager.default.fileExists(atPath: outputs[0].path)) +} + +// source 참조와 provider 수명 node 테두리를 Mermaid로 표현하는지 확인 +@Test +func diagramOutputWriterRendersSourceDependenciesAndLifetimeBorders() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + try """ + @DependencyGraph(sources: [SessionGraph.self]) + final class AppGraph { + @Provide(.shared) + private func makeRepository() -> Repository { + Repository(session: sessionGraph.session) + } + + @Provide(.transient) + private func makeFeature(repository: Repository) -> Feature { + Feature(repository: repository) + } + } + """.write(to: source, atomically: true, encoding: .utf8) + + let output = try #require( + DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ).first + ) + let mermaid = try String(contentsOf: output, encoding: .utf8) + + #expect( + mermaid.hasPrefix( + "%% CradlePlugin이 생성한 의존성 graph\n" + + "%% 모든 조건부 컴파일 절을 포함하며 실제 활성 build condition을 뜻하지 않음\n" + + "%% plugin work directory 산출물이므로 swift package clean 뒤 사라질 수 있음\n" + ) + ) + #expect(mermaid.contains("graph0_provider0 --> graph0_provider1")) + #expect(mermaid.contains("graph0_provider1 --> graph0_source0")) + #expect(mermaid.contains("classDef shared stroke:#333,stroke-width:2px;")) + #expect(mermaid.contains("classDef transient stroke:#333,stroke-width:2px,stroke-dasharray:5 5;")) +} + +// 구문 오류가 있으면 직전 성공 산출물을 유지하는지 확인 +@Test +func diagramOutputWriterPreservesExistingOutputWhenSourceIsInvalid() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + let outputDirectory = temporary.appendingPathComponent("CradleDiagrams/AppComposition") + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let existing = outputDirectory.appendingPathComponent("previous.mmd") + try "previous output".write(to: existing, atomically: true, encoding: .utf8) + try "@DependencyGraph final class AppGraph {".write(to: source, atomically: true, encoding: .utf8) + + #expect(throws: DiagramOutputError.self) { + try DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ) + } + #expect(try String(contentsOf: existing, encoding: .utf8) == "previous output") +} + +// test마다 격리된 임시 산출물 디렉터리 생성 +private func makeDiagramTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} diff --git a/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift b/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift new file mode 100644 index 0000000..690242e --- /dev/null +++ b/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift @@ -0,0 +1,187 @@ +// +// GraphDiagramParserTests.swift +// CradleGraphAnalysisTests +// +// Created by opfic on 9/4/26. +// + +import SwiftParser +import Testing +@testable import CradleGraphAnalysis + +// module 한정 graph만 인식하고 다른 module의 동명 attribute는 제외 +@Test +func graphDiagramsRecognizesQualifiedCradleGraph() { + let source = Parser.parse(source: """ + @Cradle.DependencyGraph final class AppGraph {} + @Cradle.DependencyGraph(diagram: false) final class HiddenGraph {} + @Other.DependencyGraph final class UnrelatedGraph {} + """) + #expect(graphDiagrams(in: source).map(\.lexicalName) == ["AppGraph"]) +} + +// 프로퍼티 accessor와 initializer 내부의 지역 Factory 제외 +@Test +func graphDiagramsExcludesIndirectProviders() { + let source = Parser.parse(source: """ + @DependencyGraph final class AppGraph { + var value: Int { + @Provide func nested() -> Nested { Nested() } + return 0 + } + init() { @Provide func nested() -> Nested { Nested() } } + @Provide private func direct() -> Direct { Direct() } + } + """) + #expect(graphDiagrams(in: source)[0].providers.map(\.factoryName) == ["direct"]) +} + +// source graph·provider·수명·의존성 구문 수집 확인 +@Test +func graphDiagramsCollectsGraphRelationships() { + let sourceFile = Parser.parse( + source: """ + @DependencyGraph(sources: [SessionGraph.self]) + final class AppGraph { + @Provide(.shared) + private func makeRepository() -> Repository { + Repository(session: sessionGraph.session) + } + + @Provide(.transient) + private func makeFeature(repository: Repository) -> Feature { + Feature(repository: repository) + } + + @Provide(.transient) + private func makeProfile(@External token: Token) -> Profile { + Profile(token: token) + } + } + """ + ) + + let diagram = graphDiagrams(in: sourceFile) + + #expect(diagram.map(\.lexicalName) == ["AppGraph"]) + #expect(diagram[0].sources.map(\.name) == ["sessionGraph"]) + #expect(diagram[0].providers.map(\.factoryName) == ["makeRepository", "makeFeature"]) + #expect(diagram[0].providers.map(\.lifetime) == [.shared, .transient]) + #expect(diagram[0].providers[0].sourceNames == ["sessionGraph"]) + #expect(diagram[0].providers[1].dependencyIdentities.map(\.canonicalText) == ["Repository"]) +} + +// escaped transient 수명 인자를 Mermaid 수명으로 정규화하는지 확인 +@Test +func graphDiagramsRecognizesEscapedTransientLifetime() { + let sourceFile = Parser.parse( + source: """ + @DependencyGraph + final class AppGraph { + @Provide(.`transient`) + private func makeFeature() -> Feature { Feature() } + } + """ + ) + + #expect(graphDiagrams(in: sourceFile)[0].providers.map(\.lifetime) == [.transient]) +} + +// `@External` Factory를 node와 연결에서 제외하고 본문 없는 일반 Factory는 포함하는지 확인 +@Test +func graphDiagramsExcludesExternalFactoryAndIncludesBodylessProvider() { + let sourceFile = Parser.parse( + source: """ + @DependencyGraph + final class AppGraph { + @Provide + private func makeRepository() -> Repository + + @Provide(.transient) + private func makeProfile(@Cradle.External id: UserID) -> Profile { Profile(id: id) } + } + """ + ) + + let diagram = graphDiagrams(in: sourceFile) + + #expect(diagram[0].providers.map(\.factoryName) == ["makeRepository"]) + #expect(diagram[0].providers[0].typeName == "Repository") +} + +// 중첩 선언의 lexical type 경로를 산출물 identity에 보존하는지 확인 +@Test +func graphDiagramsPreservesNestedLexicalTypePath() { + let sourceFile = Parser.parse( + source: """ + enum Composition { + struct Feature { + @DependencyGraph + final class AppGraph {} + } + } + """ + ) + + #expect(graphDiagrams(in: sourceFile).map(\.lexicalName) == ["Composition.Feature.AppGraph"]) +} + +// extension 대상 type 경로로 같은 graph 이름을 구별하는지 확인 +@Test +func graphDiagramsPreservesExtensionLexicalTypePath() { + let sourceFile = Parser.parse( + source: """ + extension FeatureA { + @DependencyGraph + final class AppGraph {} + } + + extension FeatureB { + @DependencyGraph + final class AppGraph {} + } + """ + ) + + #expect(graphDiagrams(in: sourceFile).map(\.lexicalName) == ["FeatureA.AppGraph", "FeatureB.AppGraph"]) +} + +// `diagram: false` graph는 분석 대상에서 제외하는지 확인 +@Test +func graphDiagramsSkipsDisabledGraph() { + let sourceFile = Parser.parse( + source: """ + @DependencyGraph(diagram: false) + final class DisabledGraph {} + """ + ) + + #expect(graphDiagrams(in: sourceFile).isEmpty) +} + +// 실제 build condition을 평가하지 않고 모든 `#if` 절을 수집하는지 확인 +@Test +func graphDiagramsCollectsEveryConditionalCompilationClause() { + let sourceFile = Parser.parse( + source: """ + #if DEBUG + @DependencyGraph + final class AppGraph { + @Provide + private func makeDebugFeature() -> Feature { Feature() } + } + #else + @DependencyGraph + final class AppGraph { + @Provide(.transient) + private func makeReleaseFeature() -> Feature { Feature() } + } + #endif + """ + ) + + let diagrams = graphDiagrams(in: sourceFile) + + #expect(diagrams.map(\.lexicalName) == ["AppGraph", "AppGraph"]) + #expect(diagrams.map { $0.providers[0].factoryName } == ["makeDebugFeature", "makeReleaseFeature"]) +} diff --git a/Tests/CradleGraphAnalysisTests/GraphSourceReferenceTests.swift b/Tests/CradleGraphAnalysisTests/GraphSourceReferenceTests.swift new file mode 100644 index 0000000..99736e3 --- /dev/null +++ b/Tests/CradleGraphAnalysisTests/GraphSourceReferenceTests.swift @@ -0,0 +1,41 @@ +// +// GraphSourceReferenceTests.swift +// CradleGraphAnalysisTests +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntaxBuilder +import SwiftSyntax +import Testing +@testable import CradleGraphAnalysis + +// 명시적 self와 closure capture의 source 참조를 함께 수집하는지 확인 +@Test +func graphSourceReferencesCollectsDirectReferences() throws { + let factory = try FunctionDeclSyntax( + """ + private func makeFeature() -> Feature { + let transform = { [appGraph] in appGraph.feature } + return Feature(value: self.sessionGraph.feature) + } + """ + ) + + let references = graphSourceReferences( + in: factory, + sourceNames: ["appGraph", "sessionGraph"] + ) + + #expect(references.sourceNames == ["appGraph", "sessionGraph"]) +} + +// 지역 binding이 가린 source 이름을 수집하지 않는지 확인 +@Test +func graphSourceReferencesIgnoresLocalShadowing() throws { + let factory = try FunctionDeclSyntax( + "private func makeFeature() -> Feature { let appGraph = LocalGraph(); return appGraph.feature }" + ) + + #expect(graphSourceReferences(in: factory, sourceNames: ["appGraph"]).sourceNames.isEmpty) +} diff --git a/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift b/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift new file mode 100644 index 0000000..06c8f14 --- /dev/null +++ b/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift @@ -0,0 +1,26 @@ +// +// GraphTypeIdentityTests.swift +// CradleGraphAnalysisTests +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntaxBuilder +import SwiftSyntax +import Testing +@testable import CradleGraphAnalysis + +// any와 바깥 괄호가 같은 graph 타입 identity를 만드는지 확인 +@Test +func graphTypeIdentityNormalizesAnyAndParentheses() { + let direct = TypeSyntax("Repository") + let existential = TypeSyntax("(any Repository)") + + #expect(graphTypeIdentity(for: direct) == graphTypeIdentity(for: existential)) +} + +// provider 수명의 원본 문자열을 보존하는지 확인 +@Test(arguments: [GraphProviderLifetime.shared, .transient]) +func graphProviderLifetimePreservesRawValue(lifetime: GraphProviderLifetime) { + #expect(lifetime.rawValue == (lifetime == .shared ? "shared" : "transient")) +} diff --git a/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift b/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift new file mode 100644 index 0000000..6e3f0fe --- /dev/null +++ b/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift @@ -0,0 +1,202 @@ +// +// MermaidDiagramRendererTests.swift +// CradleGraphAnalysisTests +// +// Created by opfic on 9/4/26. +// + +import Foundation +import Testing +import SwiftParser +@testable import CradleGraphAnalysis + +// 조건부 절의 동일 경로에 활성 graph가 있으면 그 anchor를 사용하는지 검증 +@Test +func targetGraphDiagramRetainsEnabledConditionalDeclaration() throws { + let collection = graphDiagramCollection(in: Parser.parse(source: """ + #if DEBUG + @DependencyGraph final class SharedGraph {} + #else + @DependencyGraph(diagram: false) final class SharedGraph {} + #endif + @DependencyGraph(sources: [SharedGraph.self]) final class AppGraph {} + """)) + let graph = try #require(collection.diagrams.first { $0.lexicalName == "AppGraph" }) + let source = try #require(graph.sources.first) + let target = TargetGraphDiagram(diagrams: collection.diagrams, excludedNames: collection.excludedNames) + #expect(target.anchor(for: source, in: graph) == "graph1_root") +} + +// 공유·순환 source와 사용하지 않은 source도 같은 graph anchor에 연결하는지 검증 +@Test +func mermaidDiagramUnifiesSharedAndCyclicGraphs() { + let diagrams = graphDiagrams(in: Parser.parse(source: """ + @DependencyGraph(sources: [BGraph.self, CGraph.self]) + final class AGraph { + @Provide func makeFeature() -> Feature { Feature(bGraph.value) } + } + @DependencyGraph(sources: [CGraph.self]) final class BGraph {} + @DependencyGraph(sources: [AGraph.self]) final class CGraph {} + """)) + let mermaid = mermaidDiagram(for: diagrams) + #expect(mermaid.contains("flowchart TB")) + #expect(mermaid.components(separatedBy: " subgraph ").count - 1 == 3) + #expect(mermaid.contains("graph0_root --> graph1_root")) + #expect(mermaid.contains("graph0_root --> graph2_root")) + #expect(mermaid.contains("graph1_root --> graph2_root")) + #expect(mermaid.contains("graph2_root --> graph0_root")) + #expect(mermaid.contains("graph0_provider0 --> graph1_root")) + #expect(mermaid.contains("subgraph graph0[\" \"]")) + #expect(!mermaid.contains("subgraph graph0[\"AGraph\"]")) + #expect(mermaid.components(separatedBy: "AGraph").count - 1 == 1) + #expect(!mermaid.contains("_source")) + #expect(mermaidDiagram(for: diagrams.reversed()) == mermaid) +} + +// 동명 provider는 graph 안에서만 연결하고 외부·제외 source의 내부는 숨기는지 검증 +@Test +func mermaidDiagramIsolatesProvidersAndExcludedGraphs() { + let diagrams = graphDiagrams(in: Parser.parse(source: """ + @DependencyGraph(sources: [HiddenGraph.self, External.Graph.self]) + final class AGraph { + @Provide func makeFeature(value: Value) -> Feature { Feature(value) } + @Provide func makeValue() -> Value { Value() } + } + @DependencyGraph final class BGraph { + @Provide func makeFeature(value: Value) -> Feature { Feature(value) } + @Provide func makeValue() -> Value { Value() } + } + @DependencyGraph(diagram: false) final class HiddenGraph { + @Provide func makeSecret() -> Secret { Secret() } + } + """)) + let mermaid = mermaidDiagram(for: diagrams) + #expect(mermaid.contains("graph0_provider0 --> graph0_provider1")) + #expect(mermaid.contains("graph1_provider0 --> graph1_provider1")) + #expect(!mermaid.contains("graph0_provider0 --> graph1_provider1")) + #expect(mermaid.contains("graph0_root --> graph0_source0")) + #expect(mermaid.contains("graph0_root --> graph0_source1")) + #expect(mermaid.contains("HiddenGraph") && mermaid.contains("External.Graph")) + #expect(!mermaid.contains("Secret")) + #expect(mermaid.components(separatedBy: " subgraph ").count - 1 == 2) +} + +// 가장 가까운 lexical scope와 명시적 경로만 연결하고 suffix 추측을 하지 않는지 검증 +@Test +func targetGraphDiagramResolvesExactLexicalSources() throws { + let diagrams = graphDiagrams(in: Parser.parse(source: """ + @DependencyGraph final class SharedGraph {} + enum Parent { + @DependencyGraph final class SharedGraph {} + enum Child { + @DependencyGraph(sources: [SharedGraph.self, Parent.SharedGraph.self, + Foreign.SharedGraph.self, MissingGraph.self, App.SharedGraph.self]) + final class AppGraph {} + } + } + enum Other { @DependencyGraph final class MissingGraph {} } + """)) + let target = TargetGraphDiagram(diagrams: diagrams) + let graph = try #require(diagrams.first { $0.lexicalName == "Parent.Child.AppGraph" }) + let resolved = Dictionary(uniqueKeysWithValues: graph.sources.map { + ($0.typeName, target.anchor(for: $0, in: graph)) + }) + #expect(resolved["SharedGraph"] == "graph2_root") + #expect(resolved["Parent.SharedGraph"] == "graph2_root") + #expect(resolved["Foreign.SharedGraph"]! == nil) + #expect(resolved["MissingGraph"]! == nil) + #expect(resolved["App.SharedGraph"]! == nil) +} + +// 조건부 동명 Factory의 수명·의존성·source 차이에도 선언 순서 독립성 보존 +@Test +func mermaidDiagramOrdersConditionalProviderVariants() { + let variants = [ + GraphDiagramProvider(factoryName: "make", typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "Feature"), lifetime: .shared, + dependencyIdentities: [], sourceNames: ["first"]), + GraphDiagramProvider(factoryName: "make", typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "Feature"), lifetime: .transient, + dependencyIdentities: [], sourceNames: ["first"]), + GraphDiagramProvider(factoryName: "make", typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "Feature"), lifetime: .shared, + dependencyIdentities: [], sourceNames: ["second"]), + GraphDiagramProvider(factoryName: "make", typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "Feature"), lifetime: .shared, + dependencyIdentities: [GraphTypeIdentity(canonicalText: "Feature")], sourceNames: []) + ] + let sources = ["first", "second"].map { + GraphDiagramSource(name: $0, typeName: $0, identity: GraphTypeIdentity(canonicalText: $0)) + } + let forward = GraphDiagram(lexicalName: "AppGraph", sourceOffset: 0, sources: sources, providers: variants) + let reverse = GraphDiagram(lexicalName: "AppGraph", sourceOffset: 0, + sources: sources.reversed(), providers: variants.reversed()) + #expect(mermaidDiagram(for: [forward]) == mermaidDiagram(for: [reverse])) +} + +// 관계와 node 수명 표현을 Mermaid 코드로 변환하는지 확인 +@Test +func mermaidDiagramRendersSolidRelationshipsAndLifetimeBorders() { + let diagram = GraphDiagram( + lexicalName: "AppGraph", + sourceOffset: 0, + sources: [ + GraphDiagramSource( + name: "sessionGraph", + typeName: "SessionGraph", + identity: GraphTypeIdentity(canonicalText: "SessionGraph") + ) + ], + providers: [ + GraphDiagramProvider( + factoryName: "makeRepository", + typeName: "Repository", + identity: GraphTypeIdentity(canonicalText: "Repository"), + lifetime: .shared, + dependencyIdentities: [], + sourceNames: ["sessionGraph"] + ), + GraphDiagramProvider( + factoryName: "makeFeature", + typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "Feature"), + lifetime: .transient, + dependencyIdentities: [GraphTypeIdentity(canonicalText: "Repository")], + sourceNames: [] + ) + ] + ) + + let mermaid = mermaidDiagram(for: [diagram]) + + #expect(mermaid.contains("graph0_provider0 --> graph0_provider1")) + #expect(mermaid.contains("graph0_provider1 --> graph0_source0")) + #expect(mermaid.contains("classDef shared stroke:#333,stroke-width:2px;")) + #expect(mermaid.contains("classDef transient stroke:#333,stroke-width:2px,stroke-dasharray:5 5;")) + #expect(!mermaid.contains("-.->")) +} + +// Mermaid node ID가 사람이 읽는 label에 의존하지 않는지 확인 +@Test +func mermaidDiagramEscapesLabelsWithoutChangingNodeIDs() { + let diagram = GraphDiagram( + lexicalName: "Feature", + sourceOffset: 0, + sources: [], + providers: [ + GraphDiagramProvider( + factoryName: "make\"Feature", + typeName: "Feature", + identity: GraphTypeIdentity(canonicalText: "FeatureProtocol"), + lifetime: .shared, + dependencyIdentities: [], + sourceNames: [] + ) + ] + ) + + let mermaid = mermaidDiagram(for: [diagram]) + + #expect(mermaid.contains("graph0_provider0[\"Feature<Protocol>
make"Feature
.shared\"]")) + #expect(!mermaid.contains("provider-")) +} diff --git a/Tests/CradleMacrosTests/DiagramConfigurationTests.swift b/Tests/CradleMacrosTests/DiagramConfigurationTests.swift new file mode 100644 index 0000000..c7eb201 --- /dev/null +++ b/Tests/CradleMacrosTests/DiagramConfigurationTests.swift @@ -0,0 +1,48 @@ +// +// DiagramConfigurationTests.swift +// CradleMacrosTests +// +// Created by opfic on 9/4/26. +// + +import SwiftSyntaxMacrosTestSupport +import Testing + +// Mermaid 산출물 표식이 Macro 확장을 바꾸지 않는지 확인 +@Test(arguments: ["true", "false"]) +func diagramConfigurationDoesNotChangeExpansion(value: String) { + assertMacroExpansion( + """ + @DependencyGraph(diagram: \(value)) + final class Graph {} + """, + expandedSource: """ + final class Graph {} + """, + macros: testMacros + ) +} + +// Mermaid 산출물 표식의 직접 Bool literal 제약 진단 확인 +@Test +func diagramConfigurationRejectsNonliteralValue() { + assertMacroExpansion( + """ + @DependencyGraph(diagram: enabled) + final class Graph {} + """, + expandedSource: """ + final class Graph {} + """, + diagnostics: [ + DiagnosticSpec( + id: .init(domain: "Cradle", id: "invalidDiagramConfiguration"), + message: "`diagram`은 직접 작성한 `true` 또는 `false`여야 합니다.", + line: 1, + column: 27, + highlights: ["enabled"] + ) + ], + macros: testMacros + ) +} diff --git a/Tests/CradleTests/CopyCradleMermaidScriptTests.swift b/Tests/CradleTests/CopyCradleMermaidScriptTests.swift new file mode 100644 index 0000000..79687be --- /dev/null +++ b/Tests/CradleTests/CopyCradleMermaidScriptTests.swift @@ -0,0 +1,145 @@ +// +// CopyCradleMermaidScriptTests.swift +// CradleTests +// +// Created by opfic on 9/5/26. +// + +import Foundation +import Testing + +// percent-encoded Xcode target namespace의 Mermaid 복사 확인 +@Test +func copyCradleMermaidScriptFindsEncodedTargetOutput() throws { + let temporary = try makeCopyScriptTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let output = temporary.appendingPathComponent( + "BuildToolPluginIntermediates/ExampleApp.output/My.App/CradlePlugin/CradleDiagrams/My%2EApp/DependencyGraph.mmd" + ) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try "graph TD\n".write(to: output, atomically: true, encoding: .utf8) + + let project = temporary.appendingPathComponent("project") + let targetTemporary = temporary.appendingPathComponent("target-temporary") + try FileManager.default.createDirectory(at: targetTemporary, withIntermediateDirectories: true) + let process = try runCopyCradleMermaidScript( + objroot: temporary, + sourceRoot: project, + targetTemporary: targetTemporary, + targetName: "My.App" + ) + + #expect(process.terminationReason == .exit) + #expect(process.terminationStatus == 0) + let destination = project.appendingPathComponent(".cradle/DependencyGraph.mmd") + #expect(try String(contentsOf: destination, encoding: .utf8) == "graph TD\n") +} + +// glob 메타문자가 있는 Xcode target 이름의 Mermaid 복사 확인 +@Test +func copyCradleMermaidScriptEscapesTargetPathPattern() throws { + let temporary = try makeCopyScriptTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let output = temporary.appendingPathComponent( + "BuildToolPluginIntermediates/ExampleApp.output/My[App]/CradlePlugin/CradleDiagrams/My%5BApp%5D/DependencyGraph.mmd" + ) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try "graph escaped target\n".write(to: output, atomically: true, encoding: .utf8) + + let project = temporary.appendingPathComponent("project") + let targetTemporary = temporary.appendingPathComponent("target-temporary") + try FileManager.default.createDirectory(at: targetTemporary, withIntermediateDirectories: true) + let process = try runCopyCradleMermaidScript( + objroot: temporary, + sourceRoot: project, + targetTemporary: targetTemporary, + targetName: "My[App]" + ) + + #expect(process.terminationReason == .exit) + #expect(process.terminationStatus == 0) + let destination = project.appendingPathComponent(".cradle/DependencyGraph.mmd") + #expect(try String(contentsOf: destination, encoding: .utf8) == "graph escaped target\n") +} + +// source 없음과 복수 후보에서 기존 Mermaid 복사본 보존 확인 +@Test +func copyCradleMermaidScriptPreservesExistingOutputForMissingOrMultipleCandidates() throws { + let temporary = try makeCopyScriptTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + + let missingProject = temporary.appendingPathComponent("missing-project") + let missingDestination = missingProject.appendingPathComponent(".cradle/DependencyGraph.mmd") + let missingTargetTemporary = temporary.appendingPathComponent("missing-target-temporary") + let missingDirectory = missingDestination.deletingLastPathComponent() + try FileManager.default.createDirectory(at: missingDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: missingTargetTemporary, withIntermediateDirectories: true) + try "previous missing\n".write(to: missingDestination, atomically: true, encoding: .utf8) + let missingProcess = try runCopyCradleMermaidScript( + objroot: temporary.appendingPathComponent("missing-objroot"), + sourceRoot: missingProject, + targetTemporary: missingTargetTemporary, + targetName: "My.App" + ) + + #expect(missingProcess.terminationStatus == 0) + #expect(try String(contentsOf: missingDestination, encoding: .utf8) == "previous missing\n") + + let multipleRoot = temporary.appendingPathComponent("multiple-objroot") + let multipleProject = temporary.appendingPathComponent("multiple-project") + let multipleDestination = multipleProject.appendingPathComponent(".cradle/DependencyGraph.mmd") + let multipleTargetTemporary = temporary.appendingPathComponent("multiple-target-temporary") + let multipleDirectory = multipleDestination.deletingLastPathComponent() + try FileManager.default.createDirectory(at: multipleDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: multipleTargetTemporary, withIntermediateDirectories: true) + try "previous multiple\n".write(to: multipleDestination, atomically: true, encoding: .utf8) + for outputName in ["first", "second"] { + let output = multipleRoot.appendingPathComponent( + "BuildToolPluginIntermediates/\(outputName)/My.App/CradlePlugin/CradleDiagrams/My%2EApp/DependencyGraph.mmd" + ) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try "new output\n".write(to: output, atomically: true, encoding: .utf8) + } + let multipleProcess = try runCopyCradleMermaidScript( + objroot: multipleRoot, + sourceRoot: multipleProject, + targetTemporary: multipleTargetTemporary, + targetName: "My.App" + ) + + #expect(multipleProcess.terminationStatus == 0) + #expect(try String(contentsOf: multipleDestination, encoding: .utf8) == "previous multiple\n") +} + +// shell script 실행에 사용할 임시 경로 생성 +private func makeCopyScriptTemporaryDirectory() throws -> URL { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + return temporary +} + +// build setting을 주입해 ExampleApp Mermaid 복사 script 실행 +private func runCopyCradleMermaidScript( + objroot: URL, + sourceRoot: URL, + targetTemporary: URL, + targetName: String +) throws -> Process { + let script = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Examples/ExampleApp/Scripts/CopyCradleMermaid.sh") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [script.path] + process.environment = ProcessInfo.processInfo.environment.merging([ + "OBJROOT": objroot.path, + "SRCROOT": sourceRoot.path, + "TARGET_TEMP_DIR": targetTemporary.path, + "TARGET_NAME": targetName + ]) { _, value in value } + try process.run() + process.waitUntilExit() + return process +} diff --git a/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift b/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift new file mode 100644 index 0000000..b50b67b --- /dev/null +++ b/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift @@ -0,0 +1,192 @@ +// +// CradlePluginIntegrationFixtureTests.swift +// CradleTests +// +// Created by opfic on 9/4/26. +// + +import Foundation +import Testing + +// 독립 소비자 package의 plugin build 결과 +private struct CradlePluginFixtureResult { + // 정상 종료와 신호 종료 구분 + let terminationReason: Process.TerminationReason + // Swift build 종료 코드 + let status: Int32 + // plugin과 compiler 출력 + let output: String + // plugin work directory 아래 생성한 Mermaid 파일 경로 + let diagramPaths: [String] + // Mermaid 파일별 생성 content + let diagramContents: [String] +} + +// 소비자 target build가 Mermaid를 plugin work directory에만 생성하는지 확인 +@Test +func cradlePluginConsumerBuildCreatesMermaidOutputOutsideTargetSources() throws { + let fixture = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("IntegrationFixtures/CradlePluginConsumer") + let result = try buildCradlePluginFixture(at: fixture) + + #expect(result.terminationReason == .exit) + #expect(result.status == 0, Comment(rawValue: result.output)) + #expect(result.diagramPaths.count == 1) + #expect(result.diagramPaths.allSatisfy { path in + path.hasSuffix("CradleDiagrams/AppComposition/DependencyGraph.mmd") && !path.contains(".bundle") + }) + #expect(result.diagramContents.contains { + $0.contains("AppGraph") && $0.contains("graph0_provider0 --> graph0_provider1") + }) + #expect(result.diagramContents.contains { $0.contains("class graph0_provider0 transient") }) + #expect(result.diagramContents.contains { $0.contains("ExplicitGraph") }) + #expect(result.diagramContents.contains { $0.contains("ExternalGraph") && !$0.contains("ExternalFeature") }) + #expect(result.diagramContents.contains { $0.contains("Composition.NestedGraph") }) + #expect(result.diagramContents.contains { $0.contains("ExtensionFeatureA.AppGraph") }) + #expect(result.diagramContents.contains { $0.contains("ExtensionFeatureB.AppGraph") }) + #expect(!result.diagramContents.contains { $0.contains("ExcludedGraph") || $0.contains("UnrelatedFeature") }) +} + +// 독립 소비자 package를 별도 scratch 경로에서 build하고 Mermaid 산출물 수집 +private func buildCradlePluginFixture(at fixture: URL) throws -> CradlePluginFixtureResult { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let scratch = temporary.appendingPathComponent("scratch") + // 반복 빌드에서 원본 fixture를 변경하지 않을 작업 복사본 + let working = temporary.appendingPathComponent("fixture") + let outputFile = temporary.appendingPathComponent("build-output.log") + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: temporary) + } + try FileManager.default.copyItem(at: fixture, to: working) + let manifest = working.appendingPathComponent("Package.swift") + let root = fixture.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let package = try String(contentsOf: manifest, encoding: .utf8) + .replacingOccurrences(of: "\"../../..\"", with: String(reflecting: root.path)) + try package.write(to: manifest, atomically: true, encoding: .utf8) + guard FileManager.default.createFile(atPath: outputFile.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let handle = try FileHandle(forWritingTo: outputFile) + defer { + try? handle.close() + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "swift", "build", + "--package-path", working.path, + "--scratch-path", scratch.path + ] + process.standardOutput = handle + process.standardError = handle + try process.run() + process.waitUntilExit() + try handle.close() + let output = String(data: try Data(contentsOf: outputFile), encoding: .utf8) ?? "" + let diagrams = try mermaidFiles(in: scratch) + let result = CradlePluginFixtureResult( + terminationReason: process.terminationReason, + status: process.terminationStatus, + output: output, + diagramPaths: diagrams.map(\.path), + diagramContents: try diagrams.map { url in + try String(contentsOf: url, encoding: .utf8) + } + ) + if process.terminationStatus == 0 { + try verifyCradlePluginRebuilds(at: working, scratch: scratch, log: outputFile) + } + return result +} + +// 같은 scratch 경로의 반복 빌드와 오류 이후 산출물 보존 검증 +private func verifyCradlePluginRebuilds(at fixture: URL, scratch: URL, log: URL) throws { + let initial = try diagramSnapshot(in: scratch) + try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: true) + #expect(try diagramSnapshot(in: scratch) == initial) + let source = fixture.appendingPathComponent("Sources/AppComposition/AppGraph.swift") + let original = try String(contentsOf: source, encoding: .utf8) + let renamed = original.replacingOccurrences(of: "ExplicitGraph", with: "RenamedGraph") + try renamed.write(to: source, atomically: true, encoding: .utf8) + try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: true) + let output = try #require(mermaidFiles(in: scratch).first) + #expect(output.lastPathComponent == "DependencyGraph.mmd") + let content = try String(contentsOf: output, encoding: .utf8) + #expect(content.contains("RenamedGraph") && !content.contains("ExplicitGraph")) + let disabled = renamed.replacingOccurrences(of: "@Cradle.DependencyGraph(diagram: true)", + with: "@Cradle.DependencyGraph(diagram: false)") + try disabled.write(to: source, atomically: true, encoding: .utf8) + try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: true) + #expect(try mermaidFiles(in: scratch).count == 1) + #expect(try !String(contentsOf: output, encoding: .utf8).contains("RenamedGraph")) + let successful = try diagramSnapshot(in: scratch) + let duplicate = """ + #if DEBUG + @DependencyGraph final class DuplicateGraph {} + #else + @DependencyGraph final class DuplicateGraph {} + #endif + """ + try (disabled + "\n" + duplicate).write(to: source, atomically: true, encoding: .utf8) + let diagnostic = try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: false) + #expect(diagnostic.contains("중복된 DependencyGraph") && diagnostic.contains(source.path)) + #expect(try diagramSnapshot(in: scratch) == successful) + try "@DependencyGraph final class Broken {".write(to: source, atomically: true, encoding: .utf8) + try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: false) + #expect(try diagramSnapshot(in: scratch) == successful) + // target는 유지하고 graph가 있는 source 파일을 삭제한 상황 검증 + try FileManager.default.removeItem(at: source) + try "struct Placeholder {}".write(to: source.deletingLastPathComponent().appendingPathComponent("Keep.swift"), + atomically: true, encoding: .utf8) + try rebuildCradlePluginFixture(at: fixture, scratch: scratch, log: log, succeeds: true) + #expect(try mermaidFiles(in: scratch).isEmpty) + #expect(try mermaidFiles(in: fixture).isEmpty) +} + +// 파일 내용과 수정 시각을 함께 보존하는 비교용 상태 +private func diagramSnapshot(in directory: URL) throws -> [String: String] { + try Dictionary(uniqueKeysWithValues: mermaidFiles(in: directory).map { url in + let date = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let content = try String(contentsOf: url, encoding: .utf8) + return (url.path, "\(String(describing: date?.timeIntervalSinceReferenceDate))\n\(content)") + }) +} + +// 실행마다 새 Process를 만들어 성공·실패와 도구 출력을 확인 +@discardableResult +private func rebuildCradlePluginFixture(at fixture: URL, scratch: URL, log: URL, succeeds: Bool) throws -> String { + let handle = try FileHandle(forWritingTo: log) + defer { try? handle.close() } + try handle.truncate(atOffset: 0) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["swift", "build", "--package-path", fixture.path, "--scratch-path", scratch.path] + process.standardOutput = handle + process.standardError = handle + try process.run() + process.waitUntilExit() + let output = try String(contentsOf: log, encoding: .utf8) + #expect(process.terminationReason == .exit) + #expect((process.terminationStatus == 0) == succeeds, Comment(rawValue: output)) + return output +} + +// SwiftPM scratch directory에서 plugin이 생성한 `.mmd` 파일만 수집 +private func mermaidFiles(in directory: URL) throws -> [URL] { + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey] + ) + let urls = try enumerator?.compactMap { element -> URL? in + guard let url = element as? URL, + url.pathExtension == "mmd", + try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { + return nil + } + return url + } ?? [] + return urls.sorted { $0.path < $1.path } +} diff --git a/Tests/IntegrationFixtures/CradlePluginConsumer/Package.swift b/Tests/IntegrationFixtures/CradlePluginConsumer/Package.swift new file mode 100644 index 0000000..56f7627 --- /dev/null +++ b/Tests/IntegrationFixtures/CradlePluginConsumer/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +// CradlePlugin Mermaid 산출물 경계를 검증하는 독립 소비자 package +let package = Package( + name: "CradlePluginConsumer", + platforms: [.macOS(.v10_15)], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + .target( + name: "AppComposition", + dependencies: [ + .product(name: "Cradle", package: "Cradle") + ], + plugins: [ + .plugin(name: "CradlePlugin", package: "Cradle") + ] + ), + .target( + name: "UnrelatedComposition", + dependencies: [.product(name: "Cradle", package: "Cradle")] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift new file mode 100644 index 0000000..b892a52 --- /dev/null +++ b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift @@ -0,0 +1,85 @@ +import Cradle + +@DependencyGraph +final class AppGraph { + @Provide + private func makeRepository() -> Repository { + Repository() + } + + @Provide(.`transient`) + private func makeFeature(repository: Repository) -> Feature { + Feature(repository: repository) + } +} + +@DependencyGraph(diagram: false) +final class ExcludedGraph { + @Provide + private func makeExcludedFeature() -> ExcludedFeature { + ExcludedFeature() + } +} + +@Cradle.DependencyGraph(diagram: true) +final class ExplicitGraph {} + +@DependencyGraph +final class ExternalGraph { + @Provide(.transient) + private func makeExternalFeature(@External id: Int) -> ExternalFeature { + ExternalFeature(id: id) + } +} + +enum Composition { + @DependencyGraph + final class NestedGraph { + @Provide + private func makeNestedFeature() -> NestedFeature { + NestedFeature() + } + } +} + +struct ExtensionFeatureA {} + +extension ExtensionFeatureA { + @DependencyGraph + final class AppGraph { + @Provide + private func makeExtensionFeature() -> ExtensionFeature { + ExtensionFeature() + } + } +} + +struct ExtensionFeatureB {} + +extension ExtensionFeatureB { + @DependencyGraph + final class AppGraph { + @Provide + private func makeExtensionFeature() -> ExtensionFeature { + ExtensionFeature() + } + } +} + +struct Repository {} + +struct Feature { + let repository: Repository +} + +struct ExcludedFeature {} + +struct ExplicitFeature {} + +struct ExternalFeature { + let id: Int +} + +struct NestedFeature {} + +struct ExtensionFeature {} diff --git a/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/UnrelatedComposition/UnrelatedFeature.swift b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/UnrelatedComposition/UnrelatedFeature.swift new file mode 100644 index 0000000..5c25e82 --- /dev/null +++ b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/UnrelatedComposition/UnrelatedFeature.swift @@ -0,0 +1,5 @@ +// plugin이 연결되지 않은 target의 Mermaid 산출물 제외 확인용 source +import Cradle + +@DependencyGraph +final class UnrelatedFeature {}