diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index bbd8237..d031f7a 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -4,13 +4,7 @@ description: Install dependencies and bundle the action runs: using: 'composite' steps: - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '24' - cache: 'npm' - - - run: npm ci - shell: bash + - uses: ./.github/actions/setup - run: npm run build shell: bash diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..ca96e99 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,13 @@ +name: Setup +description: Install Node.js and the project's npm dependencies + +runs: + using: 'composite' + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: 'npm' + + - run: npm ci + shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4204fb..60f1945 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,12 +35,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: 'npm' - - - run: npm ci + - uses: ./.github/actions/setup - run: npm run typecheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 991febe..06c7c06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,12 +16,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: 'npm' - - - run: npm ci + - uses: ./.github/actions/setup - run: npm run typecheck @@ -64,10 +59,22 @@ jobs: path: dist/ integration: + # Check names are "integration (project)" and "integration (workspace)" — both are + # required status checks on main, so keep these names in sync with branch protection. + name: integration (${{ matrix.name }}) needs: build runs-on: macos-15 permissions: contents: read + strategy: + fail-fast: false + matrix: + include: + - name: project + project_file: tests/fixtures/TestApp/TestApp.xcodeproj + - name: workspace + workspace_file: tests/fixtures/TestWorkspce/TestWorkspace.xcworkspace + scheme: TestWorkspce steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -76,63 +83,14 @@ jobs: name: dist path: dist/ + # Inputs a variant does not define expand to '', which the action treats as unset. - name: Run action id: resolve uses: ./ with: - project_file: tests/fixtures/TestApp/TestApp.xcodeproj - - - name: Verify outputs - env: - DEPENDENCIES_CHANGED: ${{ steps.resolve.outputs.dependenciesChanged }} - SUMMARY: ${{ steps.resolve.outputs.summary }} - run: | - echo "dependenciesChanged=${DEPENDENCIES_CHANGED}" - echo "summary=${SUMMARY}" - if [ "${DEPENDENCIES_CHANGED}" != "true" ]; then - echo "::error::Expected dependenciesChanged to be 'true', got '${DEPENDENCIES_CHANGED}'" - exit 1 - fi - if [ -z "${SUMMARY}" ]; then - echo "::error::Expected summary to be non-empty" - exit 1 - fi - - - name: Verify repo is clean except Package.resolved - run: | - CHANGED=$(git diff --name-only) - echo "Changed files: ${CHANGED}" - UNEXPECTED=$(echo "${CHANGED}" | grep -v -e 'Package.resolved' -e '^dist/' || true) - if [ -n "${UNEXPECTED}" ]; then - echo "::error::Unexpected changed files after action: ${UNEXPECTED}" - exit 1 - fi - UNTRACKED=$(git ls-files --others --exclude-standard) - echo "Untracked files: ${UNTRACKED}" - if [ -n "${UNTRACKED}" ]; then - echo "::error::Unexpected untracked files after action: ${UNTRACKED}" - exit 1 - fi - - integration-workspace: - needs: build - runs-on: macos-15 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: dist - path: dist/ - - - name: Run action with workspace - id: resolve - uses: ./ - with: - workspace_file: tests/fixtures/TestWorkspce/TestWorkspace.xcworkspace - scheme: TestWorkspce + project_file: ${{ matrix.project_file }} + workspace_file: ${{ matrix.workspace_file }} + scheme: ${{ matrix.scheme }} - name: Verify outputs env: @@ -173,12 +131,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: 'npm' - - - run: npm ci + - uses: ./.github/actions/setup - run: npm run lint diff --git a/dist/main/index.cjs b/dist/main/index.cjs index 9c81ba4..d254ea8 100644 --- a/dist/main/index.cjs +++ b/dist/main/index.cjs @@ -20221,7 +20221,7 @@ function parseResolved(filePath) { return pins.map((pin) => ({ identity: identityFromUrl(pin.repositoryURL), location: pin.repositoryURL, - state: pin.state ?? void 0 + state: pin.state })); } return Array.isArray(parsed.pins) ? parsed.pins : []; @@ -20233,10 +20233,6 @@ function resolveVersion(state) { } return state?.revision ?? ""; } -function getPackages(filePath) { - const pins = parseResolved(filePath); - return new Map(pins.map((pin) => [pin.identity, resolveVersion(pin.state)])); -} function getPackagesWithInfo(filePath) { const pins = parseResolved(filePath); return new Map( @@ -20249,6 +20245,9 @@ function getPackagesWithInfo(filePath) { ]) ); } +function toVersionMap(info2) { + return new Map([...info2].map(([identity, { version }]) => [identity, version])); +} function compareVersionParts(a, b) { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i]; @@ -20342,16 +20341,12 @@ function walkDirs(rootDir, excludeDir, visit) { } scan(rootDir); } -function findPackageSwiftFiles(rootDir, excludeDir) { - const results = []; - walkDirs(rootDir, excludeDir, (dir, entries) => { - for (const entry of entries) { - if (entry.isFile() && entry.name === "Package.swift") { - results.push(import_path.default.join(dir, entry.name)); - } - } - }); - return results; +function readTextOrNull(filePath) { + try { + return import_fs2.default.readFileSync(filePath, "utf8"); + } catch { + return null; + } } function extractBlocks(content, keyword) { const blocks = []; @@ -20420,12 +20415,8 @@ function extractPbxObjectBlocks(content) { return blocks; } function detectXcodeDevPackages(pbxprojPath) { - let content; - try { - content = import_fs2.default.readFileSync(pbxprojPath, "utf8"); - } catch { - return { devRefs: /* @__PURE__ */ new Set(), appRefs: /* @__PURE__ */ new Set() }; - } + const content = readTextOrNull(pbxprojPath); + if (content === null) return { devRefs: /* @__PURE__ */ new Set(), appRefs: /* @__PURE__ */ new Set() }; const remoteRefToIdentity = /* @__PURE__ */ new Map(); for (const m of content.matchAll( /(\w{24}) \/\* XCRemoteSwiftPackageReference "[^"]*" \*\/ = \{[^}]*repositoryURL = "([^"]+)"/g @@ -20436,11 +20427,8 @@ function detectXcodeDevPackages(pbxprojPath) { for (const m of content.matchAll( /(\w{24}) \/\* \S+ \*\/ = \{\s*isa = XCSwiftPackageProductDependency;\s*(?:package = (\w+) [^;]+;\s*)?productName = [^;]+;/g )) { - const depId = m[1]; - const pkgRef = m[2]; - if (pkgRef && remoteRefToIdentity.has(pkgRef)) { - prodDepToIdentity.set(depId, remoteRefToIdentity.get(pkgRef)); - } + const identity = m[2] && remoteRefToIdentity.get(m[2]); + if (identity) prodDepToIdentity.set(m[1], identity); } const devRefs = /* @__PURE__ */ new Set(); const appRefs = /* @__PURE__ */ new Set(); @@ -20459,35 +20447,33 @@ function detectXcodeDevPackages(pbxprojPath) { } } for (const identity of remoteRefToIdentity.values()) { - if (!appRefs.has(identity) && !devRefs.has(identity)) { - devRefs.add(identity); - } + if (!appRefs.has(identity)) devRefs.add(identity); } return { devRefs, appRefs }; } -function findPbxprojFiles(rootDir, excludeDir) { - const results = []; - walkDirs(rootDir, excludeDir, (dir, entries) => { +function scanProject(projectRoot, excludeDir) { + const scan = { manifests: [], xcodeDevRefs: /* @__PURE__ */ new Set(), xcodeAppRefs: /* @__PURE__ */ new Set() }; + walkDirs(projectRoot, excludeDir, (dir, entries) => { for (const entry of entries) { - if (entry.isDirectory() && entry.name.endsWith(".xcodeproj")) { - const candidate = import_path.default.join(dir, entry.name, "project.pbxproj"); - if (import_fs2.default.existsSync(candidate)) results.push(candidate); + if (entry.isFile() && entry.name === "Package.swift") { + const content = readTextOrNull(import_path.default.join(dir, entry.name)); + if (content !== null) scan.manifests.push(content); + } else if (entry.isDirectory() && entry.name.endsWith(".xcodeproj")) { + const pbxprojPath = import_path.default.join(dir, entry.name, "project.pbxproj"); + if (!import_fs2.default.existsSync(pbxprojPath)) continue; + const { devRefs, appRefs } = detectXcodeDevPackages(pbxprojPath); + for (const ref of devRefs) scan.xcodeDevRefs.add(ref); + for (const ref of appRefs) scan.xcodeAppRefs.add(ref); } } }); - return results; + return scan; } var APP_TARGET_KEYWORDS = ["target", "executableTarget", "macro"]; -function detectDevPackages(resolvedFilePath, projectRoot, excludeDir) { - const devRefs = /* @__PURE__ */ new Set(); - const appRefs = /* @__PURE__ */ new Set(); - for (const filePath of findPackageSwiftFiles(projectRoot, excludeDir)) { - let content; - try { - content = import_fs2.default.readFileSync(filePath, "utf8"); - } catch { - continue; - } +function detectDevPackages(resolvedSet, scan) { + const devRefs = new Set(scan.xcodeDevRefs); + const appRefs = new Set(scan.xcodeAppRefs); + for (const content of scan.manifests) { for (const block of extractBlocks(content, "testTarget")) { for (const ref of packageRefs(block, "product")) devRefs.add(ref); } @@ -20500,19 +20486,7 @@ function detectDevPackages(resolvedFilePath, projectRoot, excludeDir) { } } } - for (const pbxprojPath of findPbxprojFiles(projectRoot, excludeDir)) { - const { devRefs: pbxDev, appRefs: pbxApp } = detectXcodeDevPackages(pbxprojPath); - for (const ref of pbxDev) devRefs.add(ref); - for (const ref of pbxApp) appRefs.add(ref); - } - const resolved = getPackagesWithInfo(resolvedFilePath); - const result = /* @__PURE__ */ new Set(); - for (const ref of devRefs) { - if (!appRefs.has(ref) && resolved.has(ref)) { - result.add(ref); - } - } - return result; + return new Set([...devRefs].filter((ref) => !appRefs.has(ref) && resolvedSet.has(ref))); } function manifestPackageDeps(content) { const deps = []; @@ -20522,26 +20496,9 @@ function manifestPackageDeps(content) { } return deps; } -function getDirectDependencies(resolvedSet, projectRoot, excludeDir) { - const direct = /* @__PURE__ */ new Set(); - for (const filePath of findPackageSwiftFiles(projectRoot, excludeDir)) { - let content; - try { - content = import_fs2.default.readFileSync(filePath, "utf8"); - } catch { - continue; - } - for (const ref of manifestPackageDeps(content)) { - if (resolvedSet.has(ref)) direct.add(ref); - } - } - for (const pbxprojPath of findPbxprojFiles(projectRoot, excludeDir)) { - const { devRefs, appRefs } = detectXcodeDevPackages(pbxprojPath); - for (const ref of [...devRefs, ...appRefs]) { - if (resolvedSet.has(ref)) direct.add(ref); - } - } - return direct; +function getDirectDependencies(resolvedSet, scan) { + const referenced = [...scan.manifests.flatMap(manifestPackageDeps), ...scan.xcodeDevRefs, ...scan.xcodeAppRefs]; + return new Set(referenced.filter((ref) => resolvedSet.has(ref))); } function getDependencyEdges(checkoutsDir, resolvedSet) { const edges = /* @__PURE__ */ new Map(); @@ -20555,12 +20512,8 @@ function getDependencyEdges(checkoutsDir, resolvedSet) { if (!entry.isDirectory()) continue; const identity = entry.name.toLowerCase(); if (!resolvedSet.has(identity)) continue; - let content; - try { - content = import_fs2.default.readFileSync(import_path.default.join(checkoutsDir, entry.name, "Package.swift"), "utf8"); - } catch { - continue; - } + const content = readTextOrNull(import_path.default.join(checkoutsDir, entry.name, "Package.swift")); + if (content === null) continue; const children = [...new Set(manifestPackageDeps(content))].filter( (child2) => child2 !== identity && resolvedSet.has(child2) ); @@ -20593,9 +20546,9 @@ function generateMermaidGraph(afterInfo, directDeps, edges) { lines.push(" classDef direct fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;"); return lines.join("\n"); } -function buildDependencyGraph(afterInfo, projectRoot, checkoutsDir, excludeDir) { +function buildDependencyGraph(afterInfo, scan, checkoutsDir) { const resolvedSet = new Set(afterInfo.keys()); - const directDeps = getDirectDependencies(resolvedSet, projectRoot, excludeDir); + const directDeps = getDirectDependencies(resolvedSet, scan); const edges = getDependencyEdges(checkoutsDir, resolvedSet); return generateMermaidGraph(afterInfo, directDeps, edges); } @@ -20734,9 +20687,6 @@ ${graphSection} function escapeHtml(str) { return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -function getToolVersion() { - return true ? "4.0.4" : readOwnPackageVersion(); -} function normalizeGitUrl(url) { const scpMatch = /^([\w.-]+)@([\w.-]+):(.+)$/.exec(url); return scpMatch ? `ssh://${scpMatch[1]}@${scpMatch[2]}/${scpMatch[3]}` : url; @@ -20772,7 +20722,7 @@ function generateSbom(afterInfo, devPackages = /* @__PURE__ */ new Set()) { version: 1, metadata: { timestamp: (/* @__PURE__ */ new Date()).toISOString(), - tools: [{ name: "xcode-packages-update", version: getToolVersion() }] + tools: [{ name: "xcode-packages-update", version: "4.0.4" }] }, components }; @@ -20837,13 +20787,10 @@ async function run() { if (scheme && !workspaceFile) { warning("scheme input is ignored when project_file is used without workspace_file."); } - if (workspaceFile && scheme) { - const schemePath = findSharedScheme(workspaceFile, scheme); - if (!schemePath) { - throw new Error( - `Scheme "${scheme}" was not found in "${workspaceFile}" or any referenced project. Make sure the scheme exists and is marked as shared in Xcode (Product \u2192 Scheme \u2192 Manage Schemes \u2192 check "Shared").` - ); - } + if (workspaceFile && !findSharedScheme(workspaceFile, scheme)) { + throw new Error( + `Scheme "${scheme}" was not found in "${workspaceFile}" or any referenced project. Make sure the scheme exists and is marked as shared in Xcode (Product \u2192 Scheme \u2192 Manage Schemes \u2192 check "Shared").` + ); } const packageResolved = workspaceFile ? `${workspaceFile}/xcshareddata/swiftpm/Package.resolved` : `${projectFile}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`; const snapshotDir = process.env.RUNNER_TEMP || import_os3.default.tmpdir(); @@ -20866,14 +20813,17 @@ async function run() { tempDir ]; await exec("xcodebuild", xcodebuildArgs); - const before = hadExistingResolved ? getPackages(currentPackage) : /* @__PURE__ */ new Map(); - const after = getPackages(packageResolved); - const { removed, added, updated } = comparePackages(before, after); + const beforeInfo = hadExistingResolved ? getPackagesWithInfo(currentPackage) : /* @__PURE__ */ new Map(); + const afterInfo = getPackagesWithInfo(packageResolved); + const before = toVersionMap(beforeInfo); + const after = toVersionMap(afterInfo); + const diff = comparePackages(before, after); + const { removed, added, updated } = diff; if (htmlReportPath || sbomPath) { - const projectRoot = import_path2.default.resolve(projectFile ? import_path2.default.dirname(projectFile) : import_path2.default.dirname(workspaceFile)); - const devPackages = devPackagesInput ? parseDevPackages(devPackagesInput) : detectDevPackages(packageResolved, projectRoot, tempDir); - const beforeInfo = hadExistingResolved ? getPackagesWithInfo(currentPackage) : /* @__PURE__ */ new Map(); - const afterInfo = getPackagesWithInfo(packageResolved); + const projectRoot = import_path2.default.resolve(import_path2.default.dirname(projectFile || workspaceFile)); + let scan; + const getScan = () => scan ??= scanProject(projectRoot, tempDir); + const devPackages = devPackagesInput ? parseDevPackages(devPackagesInput) : detectDevPackages(new Set(afterInfo.keys()), getScan()); if (htmlReportPath) { const runGit = async (command, args) => { const { stdout } = await getExecOutput(command, args, { @@ -20884,15 +20834,8 @@ async function run() { return stdout; }; const latest = await getLatestVersions(afterInfo, runGit); - const mermaid = buildDependencyGraph(afterInfo, projectRoot, import_path2.default.join(tempDir, "checkouts"), tempDir); - const html = generateHtmlReport( - beforeInfo, - afterInfo, - { removed, added, updated }, - devPackages, - latest, - mermaid - ); + const mermaid = buildDependencyGraph(afterInfo, getScan(), import_path2.default.join(tempDir, "checkouts")); + const html = generateHtmlReport(beforeInfo, afterInfo, diff, devPackages, latest, mermaid); writeToPath(htmlReportPath, html); setOutput("html_report_path", htmlReportPath); info(`HTML dependency report written to ${htmlReportPath}`); diff --git a/eslint.config.js b/eslint.config.js index cb09956..1339342 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,11 +1,12 @@ +import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; import globals from 'globals'; -export default tseslint.config( +export default defineConfig( { - ignores: ['node_modules/**', 'dist/**', 'coverage/**', 'eslint.config.js', 'vitest.config.ts'] + ignores: ['dist/**', 'coverage/**', 'eslint.config.js', 'vitest.config.ts'] }, - ...tseslint.configs.recommended, + tseslint.configs.recommended, { languageOptions: { globals: { @@ -13,10 +14,7 @@ export default tseslint.config( } }, rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'error', - eqeqeq: 'error', - 'prefer-const': 'error' + eqeqeq: 'error' } } ); diff --git a/src/main.ts b/src/main.ts index bcb0c99..e72c985 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,15 +4,17 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { - getPackages, getPackagesWithInfo, + toVersionMap, comparePackages, generateHtmlReport, generateSbom, detectDevPackages, getLatestVersions, buildDependencyGraph, - type PackageInfo + scanProject, + type PackageInfo, + type ProjectScan } from './packages.js'; function findSharedScheme(workspaceFile: string, scheme: string): string | null { @@ -95,15 +97,12 @@ export async function run(): Promise { core.warning('scheme input is ignored when project_file is used without workspace_file.'); } - if (workspaceFile && scheme) { - const schemePath = findSharedScheme(workspaceFile, scheme); - if (!schemePath) { - throw new Error( - `Scheme "${scheme}" was not found in "${workspaceFile}" or any referenced project. ` + - `Make sure the scheme exists and is marked as shared in Xcode ` + - `(Product → Scheme → Manage Schemes → check "Shared").` - ); - } + if (workspaceFile && !findSharedScheme(workspaceFile, scheme)) { + throw new Error( + `Scheme "${scheme}" was not found in "${workspaceFile}" or any referenced project. ` + + `Make sure the scheme exists and is marked as shared in Xcode ` + + `(Product → Scheme → Manage Schemes → check "Shared").` + ); } const packageResolved = workspaceFile @@ -126,7 +125,7 @@ export async function run(): Promise { // newer compatible versions when there is no existing lockfile to satisfy, so this is what // makes -resolvePackageDependencies actually surface available updates. If xcodebuild never // gets to rewrite packageResolved (crash, error), the post step restores this snapshot back - // to packageResolvedPath instead of leaving the workspace with a missing file. + // to packageResolved instead of leaving the workspace with a missing file. const hadExistingResolved = fs.existsSync(packageResolved); if (hadExistingResolved) { fs.renameSync(packageResolved, currentPackage); @@ -144,18 +143,23 @@ export async function run(): Promise { await exec.exec('xcodebuild', xcodebuildArgs); - const before = hadExistingResolved ? getPackages(currentPackage) : new Map(); - const after = getPackages(packageResolved); - const { removed, added, updated } = comparePackages(before, after); + const beforeInfo = hadExistingResolved ? getPackagesWithInfo(currentPackage) : new Map(); + const afterInfo = getPackagesWithInfo(packageResolved); + const before = toVersionMap(beforeInfo); + const after = toVersionMap(afterInfo); + const diff = comparePackages(before, after); + const { removed, added, updated } = diff; if (htmlReportPath || sbomPath) { - const projectRoot = path.resolve(projectFile ? path.dirname(projectFile) : path.dirname(workspaceFile)); + const projectRoot = path.resolve(path.dirname(projectFile || workspaceFile)); + // Walked lazily and at most once: dev-package detection and the dependency graph share it, + // and neither needs it when development_packages is given and only the SBOM is requested. + let scan: ProjectScan | undefined; + const getScan = (): ProjectScan => (scan ??= scanProject(projectRoot, tempDir)); + const devPackages = devPackagesInput ? parseDevPackages(devPackagesInput) - : detectDevPackages(packageResolved, projectRoot, tempDir); - - const beforeInfo = hadExistingResolved ? getPackagesWithInfo(currentPackage) : new Map(); - const afterInfo = getPackagesWithInfo(packageResolved); + : detectDevPackages(new Set(afterInfo.keys()), getScan()); if (htmlReportPath) { const runGit = async (command: string, args: string[]): Promise => { @@ -167,15 +171,8 @@ export async function run(): Promise { return stdout; }; const latest = await getLatestVersions(afterInfo, runGit); - const mermaid = buildDependencyGraph(afterInfo, projectRoot, path.join(tempDir, 'checkouts'), tempDir); - const html = generateHtmlReport( - beforeInfo, - afterInfo, - { removed, added, updated }, - devPackages, - latest, - mermaid - ); + const mermaid = buildDependencyGraph(afterInfo, getScan(), path.join(tempDir, 'checkouts')); + const html = generateHtmlReport(beforeInfo, afterInfo, diff, devPackages, latest, mermaid); writeToPath(htmlReportPath, html); core.setOutput('html_report_path', htmlReportPath); core.info(`HTML dependency report written to ${htmlReportPath}`); diff --git a/src/packages.ts b/src/packages.ts index 665a9f0..fac2f18 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -40,7 +40,7 @@ function parseResolved(filePath: string): ResolvedPin[] { return pins.map((pin) => ({ identity: identityFromUrl(pin.repositoryURL), location: pin.repositoryURL, - state: pin.state ?? undefined + state: pin.state })); } @@ -60,11 +60,6 @@ function resolveVersion(state: ResolvedPin['state']): string { return state?.revision ?? ''; } -export function getPackages(filePath: string): Map { - const pins = parseResolved(filePath); - return new Map(pins.map((pin) => [pin.identity, resolveVersion(pin.state)])); -} - export function getPackagesWithInfo(filePath: string): Map { const pins = parseResolved(filePath); return new Map( @@ -78,6 +73,11 @@ export function getPackagesWithInfo(filePath: string): Map ); } +/** Projects parsed package info down to identity → version, the shape comparePackages diffs. */ +export function toVersionMap(info: Map): Map { + return new Map([...info].map(([identity, { version }]) => [identity, version])); +} + export interface CompareResult { removed: string[]; added: string[]; @@ -200,7 +200,7 @@ export function comparePackages(before: Map, after: Map { - for (const entry of entries) { - if (entry.isFile() && entry.name === 'Package.swift') { - results.push(path.join(dir, entry.name)); - } - } - }); - return results; +/** Reads a UTF-8 file, returning null when it is missing or unreadable. */ +function readTextOrNull(filePath: string): string | null { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return null; + } } /** @@ -352,12 +344,8 @@ function extractPbxObjectBlocks(content: string): string[] { * plugin (e.g. SwiftLintPlugins). */ export function detectXcodeDevPackages(pbxprojPath: string): { devRefs: Set; appRefs: Set } { - let content: string; - try { - content = fs.readFileSync(pbxprojPath, 'utf8'); - } catch { - return { devRefs: new Set(), appRefs: new Set() }; - } + const content = readTextOrNull(pbxprojPath); + if (content === null) return { devRefs: new Set(), appRefs: new Set() }; // XCRemoteSwiftPackageReference id → identity const remoteRefToIdentity = new Map(); @@ -372,11 +360,8 @@ export function detectXcodeDevPackages(pbxprojPath: string): { devRefs: Set { +/** Raw facts from a single walk of the project sources, shared by dev-package detection and the dependency graph. */ +export interface ProjectScan { + /** Contents of every readable Package.swift under the project root. */ + manifests: string[]; + /** Remote packages from every project.pbxproj linked only to test targets (or to no target at all). */ + xcodeDevRefs: Set; + /** Remote packages from every project.pbxproj linked to at least one non-test target. */ + xcodeAppRefs: Set; +} + +/** + * Walks `projectRoot` once, reading every Package.swift manifest and classifying the remote + * packages of every `.xcodeproj`. `excludeDir`, when given, is skipped entirely — used to keep + * the action's own temporary_packages_dir_path (whose cloned checkouts are not project sources) + * out of the scan, since it may sit under the project root. + */ +export function scanProject(projectRoot: string, excludeDir?: string): ProjectScan { + const scan: ProjectScan = { manifests: [], xcodeDevRefs: new Set(), xcodeAppRefs: new Set() }; + + walkDirs(projectRoot, excludeDir, (dir, entries) => { for (const entry of entries) { - if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { - const candidate = path.join(dir, entry.name, 'project.pbxproj'); - if (fs.existsSync(candidate)) results.push(candidate); + if (entry.isFile() && entry.name === 'Package.swift') { + const content = readTextOrNull(path.join(dir, entry.name)); + if (content !== null) scan.manifests.push(content); + } else if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { + const pbxprojPath = path.join(dir, entry.name, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) continue; + const { devRefs, appRefs } = detectXcodeDevPackages(pbxprojPath); + for (const ref of devRefs) scan.xcodeDevRefs.add(ref); + for (const ref of appRefs) scan.xcodeAppRefs.add(ref); } } }); - return results; + + return scan; } /** Non-test target kinds that count as app (production) targets for dev-package detection. */ const APP_TARGET_KEYWORDS = ['target', 'executableTarget', 'macro']; /** - * Scans Package.swift files and project.pbxproj files in `projectRoot` and returns - * identities that are referenced exclusively in test targets or as build tool plugins — - * never in regular app targets. `excludeDir`, when given, is skipped during the scan (e.g. - * the action's own temporary_packages_dir_path, whose cloned checkouts are not project sources). - * Falls back gracefully: if parsing yields no results the set is empty (no false positives). + * Returns resolved identities that the scanned project references exclusively in test targets + * or as build tool plugins — never in regular app targets. Falls back gracefully: if parsing + * yields no results the set is empty (no false positives). */ -export function detectDevPackages(resolvedFilePath: string, projectRoot: string, excludeDir?: string): Set { - const devRefs = new Set(); - const appRefs = new Set(); - - // ── Package.swift scan ────────────────────────────────────────────────── - for (const filePath of findPackageSwiftFiles(projectRoot, excludeDir)) { - let content: string; - try { - content = fs.readFileSync(filePath, 'utf8'); - } catch { - continue; - } +export function detectDevPackages(resolvedSet: Set, scan: ProjectScan): Set { + const devRefs = new Set(scan.xcodeDevRefs); + const appRefs = new Set(scan.xcodeAppRefs); + for (const content of scan.manifests) { for (const block of extractBlocks(content, 'testTarget')) { for (const ref of packageRefs(block, 'product')) devRefs.add(ref); } @@ -463,24 +459,8 @@ export function detectDevPackages(resolvedFilePath: string, projectRoot: string, } } - // ── project.pbxproj scan ──────────────────────────────────────────────── - for (const pbxprojPath of findPbxprojFiles(projectRoot, excludeDir)) { - const { devRefs: pbxDev, appRefs: pbxApp } = detectXcodeDevPackages(pbxprojPath); - for (const ref of pbxDev) devRefs.add(ref); - for (const ref of pbxApp) appRefs.add(ref); - } - // Packages only in dev context, never in app context - const resolved = getPackagesWithInfo(resolvedFilePath); - const result = new Set(); - - for (const ref of devRefs) { - if (!appRefs.has(ref) && resolved.has(ref)) { - result.add(ref); - } - } - - return result; + return new Set([...devRefs].filter((ref) => !appRefs.has(ref) && resolvedSet.has(ref))); } // ─── Dependency graph ──────────────────────────────────────────────────────── @@ -504,32 +484,13 @@ function manifestPackageDeps(content: string): string[] { /** * Returns the project's direct SPM dependencies (the graph roots): every package the * project itself references, gathered from project.pbxproj remote references and from - * Package.swift manifests located in the project root. Only identities present in - * Package.resolved are kept. `excludeDir`, when given, is skipped during the scan. + * Package.swift `.package(url:)` declarations. Only identities present in Package.resolved + * are kept. */ -export function getDirectDependencies(resolvedSet: Set, projectRoot: string, excludeDir?: string): Set { - const direct = new Set(); - - for (const filePath of findPackageSwiftFiles(projectRoot, excludeDir)) { - let content: string; - try { - content = fs.readFileSync(filePath, 'utf8'); - } catch { - continue; - } - for (const ref of manifestPackageDeps(content)) { - if (resolvedSet.has(ref)) direct.add(ref); - } - } - - for (const pbxprojPath of findPbxprojFiles(projectRoot, excludeDir)) { - const { devRefs, appRefs } = detectXcodeDevPackages(pbxprojPath); - for (const ref of [...devRefs, ...appRefs]) { - if (resolvedSet.has(ref)) direct.add(ref); - } - } - - return direct; +export function getDirectDependencies(resolvedSet: Set, scan: ProjectScan): Set { + // detectXcodeDevPackages puts every remote package reference into at least one of the two sets. + const referenced = [...scan.manifests.flatMap(manifestPackageDeps), ...scan.xcodeDevRefs, ...scan.xcodeAppRefs]; + return new Set(referenced.filter((ref) => resolvedSet.has(ref))); } /** @@ -555,12 +516,8 @@ export function getDependencyEdges(checkoutsDir: string, resolvedSet: Set child !== identity && resolvedSet.has(child) @@ -614,18 +571,17 @@ export function generateMermaidGraph( } /** - * Orchestrator: reads the project sources and the cloned checkouts and returns a - * Mermaid graph definition for the dependency tree. `afterInfo` is the already-parsed - * resolved package set, so the resolved file is not read again here. + * Orchestrator: combines the project scan and the cloned checkouts into a Mermaid graph + * definition for the dependency tree. `afterInfo` and `scan` are already computed by the + * caller, so neither the resolved file nor the project tree is read again here. */ export function buildDependencyGraph( afterInfo: Map, - projectRoot: string, - checkoutsDir: string, - excludeDir?: string + scan: ProjectScan, + checkoutsDir: string ): string { const resolvedSet = new Set(afterInfo.keys()); - const directDeps = getDirectDependencies(resolvedSet, projectRoot, excludeDir); + const directDeps = getDirectDependencies(resolvedSet, scan); const edges = getDependencyEdges(checkoutsDir, resolvedSet); return generateMermaidGraph(afterInfo, directDeps, edges); } @@ -810,25 +766,9 @@ function escapeHtml(str: string): string { // ─── CycloneDX SBOM ────────────────────────────────────────────────────────── -// Replaced with a literal version string by esbuild's --define at build time (see package.json). -declare const __PACKAGE_VERSION__: string | undefined; - -/** Reads this package's own version from package.json — used only when __PACKAGE_VERSION__ wasn't injected (e.g. tests). */ -function readOwnPackageVersion(): string { - try { - const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) as { - version?: string; - }; - return pkg.version ?? '0.0.0'; - } catch { - return '0.0.0'; - } -} - -/** Resolved lazily (not at module load) so importing this module never touches the filesystem. */ -function getToolVersion(): string { - return typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ : readOwnPackageVersion(); -} +// Replaced with a literal version string by esbuild's --define at build time (see package.json) +// and by the matching `define` in vitest.config.ts under test. +declare const __PACKAGE_VERSION__: string; /** Normalizes an scp-style git URL (git@host:org/repo.git) into a form new URL() can parse. */ function normalizeGitUrl(url: string): string { @@ -873,7 +813,7 @@ export function generateSbom(afterInfo: Map, devPackages: S version: 1, metadata: { timestamp: new Date().toISOString(), - tools: [{ name: 'xcode-packages-update', version: getToolVersion() }] + tools: [{ name: 'xcode-packages-update', version: __PACKAGE_VERSION__ }] }, components }; diff --git a/tests/main.test.ts b/tests/main.test.ts index 0242cb5..a6dbbf1 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -14,7 +14,7 @@ let mockRenameSync: ReturnType; let mockMkdirSync: ReturnType; let mockExistsSync: ReturnType; let mockReadFileSync: ReturnType; -let mockGetPackages: ReturnType; +let mockToVersionMap: ReturnType; let mockGetPackagesWithInfo: ReturnType; let mockComparePackages: ReturnType; let mockGenerateHtmlReport: ReturnType; @@ -22,6 +22,7 @@ let mockGenerateSbom: ReturnType; let mockDetectDevPackages: ReturnType; let mockGetLatestVersions: ReturnType; let mockBuildDependencyGraph: ReturnType; +let mockScanProject: ReturnType; let mockWriteFileSync: ReturnType; beforeEach(() => { @@ -38,7 +39,7 @@ beforeEach(() => { mockMkdirSync = vi.fn(); mockExistsSync = vi.fn().mockReturnValue(true); mockReadFileSync = vi.fn().mockReturnValue(''); - mockGetPackages = vi.fn().mockReturnValue(new Map()); + mockToVersionMap = vi.fn().mockReturnValue(new Map()); mockGetPackagesWithInfo = vi.fn().mockReturnValue(new Map()); mockComparePackages = vi.fn().mockReturnValue({ removed: [], added: [], updated: [] }); mockGenerateHtmlReport = vi.fn().mockReturnValue(''); @@ -46,6 +47,7 @@ beforeEach(() => { mockDetectDevPackages = vi.fn().mockReturnValue(new Set()); mockGetLatestVersions = vi.fn().mockResolvedValue(new Map()); mockBuildDependencyGraph = vi.fn().mockReturnValue(''); + mockScanProject = vi.fn().mockReturnValue({ manifests: [], xcodeDevRefs: new Set(), xcodeAppRefs: new Set() }); mockWriteFileSync = vi.fn(); vi.resetModules(); @@ -75,14 +77,15 @@ beforeEach(() => { })); vi.doMock('../src/packages.js', () => ({ - getPackages: mockGetPackages, getPackagesWithInfo: mockGetPackagesWithInfo, + toVersionMap: mockToVersionMap, comparePackages: mockComparePackages, generateHtmlReport: mockGenerateHtmlReport, generateSbom: mockGenerateSbom, detectDevPackages: mockDetectDevPackages, getLatestVersions: mockGetLatestVersions, - buildDependencyGraph: mockBuildDependencyGraph + buildDependencyGraph: mockBuildDependencyGraph, + scanProject: mockScanProject })); mockGetInput.mockImplementation((name: string) => { @@ -157,6 +160,17 @@ describe('main', () => { expect(mockRenameSync).not.toHaveBeenCalled(); }); + test('parses each Package.resolved exactly once', async () => { + const run = await loadRun(); + await run(); + + expect(mockGetPackagesWithInfo).toHaveBeenCalledTimes(2); + expect(mockGetPackagesWithInfo).toHaveBeenCalledWith(expect.stringContaining('CurrentPackage.resolved')); + expect(mockGetPackagesWithInfo).toHaveBeenCalledWith( + 'MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved' + ); + }); + test('creates tempDir before xcodebuild', async () => { const run = await loadRun(); await run(); @@ -189,7 +203,7 @@ describe('main', () => { test('removed package → dependenciesChanged true, summary contains removed', async () => { const before = new Map([['firebase', '11.0.0']]); const after = new Map(); - mockGetPackages.mockReturnValueOnce(before).mockReturnValueOnce(after); + mockToVersionMap.mockReturnValueOnce(before).mockReturnValueOnce(after); mockComparePackages.mockReturnValue({ removed: ['firebase'], added: [], updated: [] }); const run = await loadRun(); @@ -202,7 +216,7 @@ describe('main', () => { test('added package → dependenciesChanged true, summary contains added', async () => { const before = new Map(); const after = new Map([['jwt', '3.0.0']]); - mockGetPackages.mockReturnValueOnce(before).mockReturnValueOnce(after); + mockToVersionMap.mockReturnValueOnce(before).mockReturnValueOnce(after); mockComparePackages.mockReturnValue({ removed: [], added: ['jwt'], updated: [] }); const run = await loadRun(); @@ -215,7 +229,7 @@ describe('main', () => { test('updated package → dependenciesChanged true, summary contains updated', async () => { const before = new Map([['firebase', '11.0.0']]); const after = new Map([['firebase', '11.1.0']]); - mockGetPackages.mockReturnValueOnce(before).mockReturnValueOnce(after); + mockToVersionMap.mockReturnValueOnce(before).mockReturnValueOnce(after); mockComparePackages.mockReturnValue({ removed: [], added: [], updated: ['firebase'] }); const run = await loadRun(); @@ -234,7 +248,7 @@ describe('main', () => { ['firebase', '11.1.0'], ['new-pkg', '1.0.0'] ]); - mockGetPackages.mockReturnValueOnce(before).mockReturnValueOnce(after); + mockToVersionMap.mockReturnValueOnce(before).mockReturnValueOnce(after); mockComparePackages.mockReturnValue({ removed: ['jwt'], added: ['new-pkg'], updated: ['firebase'] }); const run = await loadRun(); @@ -526,9 +540,8 @@ describe('html report generation', () => { expect(mockBuildDependencyGraph).toHaveBeenCalledWith( expect.any(Map), - expect.any(String), - expect.stringContaining('checkouts'), - '.spm-tmp' + mockScanProject.mock.results[0].value, + expect.stringContaining('checkouts') ); expect(mockGenerateHtmlReport.mock.calls[0][5]).toBe('flowchart TD\n n0["firebase"]'); }); @@ -687,6 +700,54 @@ describe('dev package auto-detection', () => { expect(mockGenerateHtmlReport.mock.calls[0][3]).toBe(detected); }); + + test('passes the resolved identities from the parsed Package.resolved to detectDevPackages', async () => { + mockGetPackagesWithInfo.mockReturnValue(new Map([['pactswift', { version: '1.0.0', url: '' }]])); + mockGetInput.mockImplementation((name: string) => { + if (name === 'project_file') return 'MyApp.xcodeproj'; + if (name === 'temporary_packages_dir_path') return '.spm-tmp'; + if (name === 'sbom_path') return 'sbom.json'; + return ''; + }); + + const run = await loadRun(); + await run(); + + expect(mockDetectDevPackages).toHaveBeenCalledWith( + new Set(['pactswift']), + mockScanProject.mock.results[0].value + ); + }); + + test('scans the project tree once when both dev detection and the dependency graph need it', async () => { + mockGetInput.mockImplementation((name: string) => { + if (name === 'project_file') return 'MyApp.xcodeproj'; + if (name === 'temporary_packages_dir_path') return '.spm-tmp'; + if (name === 'html_report_path') return 'deps.html'; + return ''; + }); + + const run = await loadRun(); + await run(); + + expect(mockScanProject).toHaveBeenCalledTimes(1); + expect(mockScanProject).toHaveBeenCalledWith(expect.any(String), '.spm-tmp'); + }); + + test('does not scan the project when development_packages is given and only the SBOM is requested', async () => { + mockGetInput.mockImplementation((name: string) => { + if (name === 'project_file') return 'MyApp.xcodeproj'; + if (name === 'temporary_packages_dir_path') return '.spm-tmp'; + if (name === 'sbom_path') return 'sbom.json'; + if (name === 'development_packages') return 'pactswift'; + return ''; + }); + + const run = await loadRun(); + await run(); + + expect(mockScanProject).not.toHaveBeenCalled(); + }); }); describe('dev package auto-detection with workspace', () => { @@ -700,15 +761,12 @@ describe('dev package auto-detection with workspace', () => { }); }); - test('calls detectDevPackages with workspace directory as project root', async () => { + test('scans the workspace directory as project root for dev-package detection', async () => { const run = await loadRun(); await run(); - expect(mockDetectDevPackages).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('path/to'), - '.spm-tmp' - ); + expect(mockScanProject).toHaveBeenCalledWith(expect.stringContaining('path/to'), '.spm-tmp'); + expect(mockDetectDevPackages).toHaveBeenCalledWith(expect.any(Set), mockScanProject.mock.results[0].value); }); test('does not call detectDevPackages when development_packages is provided', async () => { diff --git a/tests/packages.test.ts b/tests/packages.test.ts index f3fb845..c16fbde 100644 --- a/tests/packages.test.ts +++ b/tests/packages.test.ts @@ -1,7 +1,8 @@ +import type { Dirent } from 'fs'; import { vi, beforeEach, describe, test, expect } from 'vitest'; +import { version as packageVersion } from '../package.json'; import { comparePackages } from '../src/packages.js'; import type { - getPackages as GetPackagesFn, getPackagesWithInfo as GetPackagesWithInfoFn, generateHtmlReport as GenerateHtmlReportFn, generateSbom as GenerateSbomFn, @@ -27,9 +28,27 @@ beforeEach(() => { })); }); -async function loadGetPackages(): Promise { - const { getPackages } = await import('../src/packages.js'); - return getPackages; +/** Version parsing as main.ts consumes it: getPackagesWithInfo projected through toVersionMap. */ +async function loadGetPackages(): Promise<(filePath: string) => Map> { + const { getPackagesWithInfo, toVersionMap } = await import('../src/packages.js'); + return (filePath) => toVersionMap(getPackagesWithInfo(filePath)); +} + +/** Dev-package detection as main.ts wires it: resolved identities plus a single project scan. */ +async function loadDetectDevPackages(): Promise< + (resolvedPath: string, projectRoot: string, excludeDir?: string) => Set +> { + const { detectDevPackages, getPackagesWithInfo, scanProject } = await import('../src/packages.js'); + return (resolvedPath, projectRoot, excludeDir) => + detectDevPackages(new Set(getPackagesWithInfo(resolvedPath).keys()), scanProject(projectRoot, excludeDir)); +} + +async function loadGetDirectDependencies(): Promise< + (resolvedSet: Set, projectRoot: string, excludeDir?: string) => Set +> { + const { getDirectDependencies, scanProject } = await import('../src/packages.js'); + return (resolvedSet, projectRoot, excludeDir) => + getDirectDependencies(resolvedSet, scanProject(projectRoot, excludeDir)); } async function loadGetPackagesWithInfo(): Promise { @@ -57,12 +76,12 @@ async function loadGetLatestVersions(): Promise - ({ name, isFile: () => !isDir, isDirectory: () => isDir }) as unknown as fs.Dirent; +const makeEntry = (name: string, isDir: boolean): Dirent => + ({ name, isFile: () => !isDir, isDirectory: () => isDir }) as unknown as Dirent; const makeInfo = (version: string, url = 'https://github.com/org/repo') => ({ version, url }); -describe('getPackages', () => { +describe('toVersionMap(getPackagesWithInfo())', () => { test('parses version from pins', async () => { mockReadFileSync.mockReturnValue( JSON.stringify({ @@ -857,7 +876,7 @@ describe('getDependencyEdges', () => { describe('getDirectDependencies', () => { test('collects direct deps declared in the project Package.swift', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockReturnValue([makeEntry('Package.swift', false)]); mockReadFileSync.mockReturnValue( '.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0")' @@ -870,7 +889,7 @@ describe('getDirectDependencies', () => { }); test('skips a project Package.swift that cannot be read', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockReturnValue([makeEntry('Package.swift', false)]); mockReadFileSync.mockImplementation(() => { throw new Error('EACCES'); @@ -882,7 +901,7 @@ describe('getDirectDependencies', () => { }); test('collects direct deps from project.pbxproj remote references', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockImplementation((dir: string) => { if (String(dir) === '.') return [makeEntry('MyApp.xcodeproj', true)]; return []; @@ -898,7 +917,7 @@ describe('getDirectDependencies', () => { }); test('ignores a declared dependency that is not in the resolved set', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockReturnValue([makeEntry('Package.swift', false)]); mockReadFileSync.mockReturnValue('.package(url: "https://github.com/x/not-resolved", from: "1.0.0")'); @@ -908,7 +927,7 @@ describe('getDirectDependencies', () => { }); test('ignores a pbxproj remote reference that is not in the resolved set', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockImplementation((dir: string) => { if (String(dir) === '.') return [makeEntry('MyApp.xcodeproj', true)]; return []; @@ -924,7 +943,7 @@ describe('getDirectDependencies', () => { }); test('finds a project.pbxproj referenced one directory level below the workspace (e.g. container:SubDir/App.xcodeproj)', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockImplementation((dir: string) => { if (String(dir) === '.') return [makeEntry('SubDir', true)]; if (String(dir) === 'SubDir') return [makeEntry('MyApp.xcodeproj', true)]; @@ -941,7 +960,7 @@ describe('getDirectDependencies', () => { }); test('excludes the given excludeDir from both the Package.swift and pbxproj scan', async () => { - const { getDirectDependencies } = await import('../src/packages.js'); + const getDirectDependencies = await loadGetDirectDependencies(); mockReaddirSync.mockImplementation((dir: string) => { if (String(dir) === '.') return [makeEntry('custom-tmp-dir', true)]; throw new Error(`should not scan excluded dir: ${dir}`); @@ -953,9 +972,33 @@ describe('getDirectDependencies', () => { }); }); +describe('scanProject', () => { + test('collects manifests and pbxproj classifications in a single walk of the tree', async () => { + const { scanProject } = await import('../src/packages.js'); + mockReaddirSync.mockImplementation((dir: string) => { + if (String(dir) === '.') return [makeEntry('Package.swift', false), makeEntry('MyApp.xcodeproj', true)]; + return []; + }); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => + String(p).endsWith('Package.swift') + ? '.package(url: "https://github.com/apple/swift-protobuf", from: "1.0.0")' + : 'AAAAAAAAAAAAAAAAAAAAAAAA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { repositoryURL = "https://github.com/firebase/firebase-ios-sdk" }' + ); + + const scan = scanProject('.'); + + expect(scan.manifests).toEqual(['.package(url: "https://github.com/apple/swift-protobuf", from: "1.0.0")']); + // A remote reference linked to no target is classified as dev (build-tool plugin heuristic). + expect(scan.xcodeDevRefs).toEqual(new Set(['firebase-ios-sdk'])); + expect(scan.xcodeAppRefs).toEqual(new Set()); + expect(mockReaddirSync.mock.calls.filter(([dir]) => String(dir) === '.')).toHaveLength(1); + }); +}); + describe('buildDependencyGraph', () => { test('produces a mermaid graph from the resolved set, project sources and checkouts', async () => { - const { buildDependencyGraph } = await import('../src/packages.js'); + const { buildDependencyGraph, scanProject } = await import('../src/packages.js'); const afterInfo = new Map([ ['firebase-ios-sdk', { version: '1.0.0', url: 'https://github.com/firebase/firebase-ios-sdk' }], ['swift-protobuf', { version: '1.0.0', url: 'https://github.com/apple/swift-protobuf' }] @@ -970,7 +1013,7 @@ describe('buildDependencyGraph', () => { return '.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0")'; }); - const graph = buildDependencyGraph(afterInfo, '.', '/tmp/checkouts'); + const graph = buildDependencyGraph(afterInfo, scanProject('.'), '/tmp/checkouts'); expect(graph).toContain('flowchart TD'); expect(graph).toContain('firebase-ios-sdk'); @@ -1092,44 +1135,16 @@ describe('generateSbom', () => { expect(sbom.components[0].purl).toBe('pkg:swift/github.com/org/mypkg'); }); - test('reads the tool version from package.json when it is not injected at build time', async () => { - mockReadFileSync.mockReturnValue(JSON.stringify({ name: 'xcode-packages-update', version: '9.9.9' })); - + test('reports the build-time injected __PACKAGE_VERSION__ as the tool version without touching the filesystem', async () => { const generateSbom = await loadGenerateSbom(); const sbom = JSON.parse(generateSbom(new Map())); - expect(sbom.metadata.tools[0].version).toBe('9.9.9'); - }); - - test('falls back to 0.0.0 when package.json has no version field', async () => { - mockReadFileSync.mockReturnValue(JSON.stringify({ name: 'xcode-packages-update' })); - - const generateSbom = await loadGenerateSbom(); - const sbom = JSON.parse(generateSbom(new Map())); - - expect(sbom.metadata.tools[0].version).toBe('0.0.0'); - }); - - test('uses the build-time injected version when __PACKAGE_VERSION__ is defined', async () => { - (globalThis as unknown as { __PACKAGE_VERSION__?: string }).__PACKAGE_VERSION__ = '3.2.1'; - try { - const generateSbom = await loadGenerateSbom(); - const sbom = JSON.parse(generateSbom(new Map())); - - expect(sbom.metadata.tools[0].version).toBe('3.2.1'); - expect(mockReadFileSync).not.toHaveBeenCalled(); - } finally { - delete (globalThis as unknown as { __PACKAGE_VERSION__?: string }).__PACKAGE_VERSION__; - } + expect(sbom.metadata.tools[0].version).toBe(packageVersion); + expect(mockReadFileSync).not.toHaveBeenCalled(); }); }); describe('detectDevPackages', () => { - async function loadDetectDevPackages(): Promise { - const { detectDevPackages } = await import('../src/packages.js'); - return detectDevPackages; - } - beforeEach(() => { mockReaddirSync = vi.fn(); mockExistsSync = vi.fn().mockReturnValue(false); @@ -1682,7 +1697,7 @@ describe('detectXcodeDevPackages', () => { }); }); -describe('findPbxprojFiles existsSync true path', () => { +describe('scanProject .xcodeproj discovery', () => { test('includes pbxproj path in detectDevPackages when existsSync returns true', async () => { const pbxproj = [ `AAAAAAAAAAAAAAAAAAAAAAAA /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing" }`, @@ -1717,7 +1732,7 @@ describe('findPbxprojFiles existsSync true path', () => { return pbxproj; }); - const { detectDevPackages } = await import('../src/packages.js'); + const detectDevPackages = await loadDetectDevPackages(); const result = detectDevPackages('Package.resolved', '.'); expect(mockExistsSync).toHaveBeenCalled(); @@ -1761,7 +1776,7 @@ describe('findPbxprojFiles existsSync true path', () => { return pbxproj; }); - const { detectDevPackages } = await import('../src/packages.js'); + const detectDevPackages = await loadDetectDevPackages(); const result = detectDevPackages('Package.resolved', '.'); expect(result.has('firebase-ios-sdk')).toBe(false); @@ -1787,7 +1802,7 @@ describe('findPbxprojFiles existsSync true path', () => { return ''; }); - const { detectDevPackages } = await import('../src/packages.js'); + const detectDevPackages = await loadDetectDevPackages(); const result = detectDevPackages('Package.resolved', '.'); expect(mockExistsSync).toHaveBeenCalledWith('Futurum.xcodeproj/project.pbxproj'); diff --git a/tsconfig.json b/tsconfig.json index 9241f95..eda14c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,5 @@ "noUnusedLocals": true, "noEmit": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*", "tests/**/*", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index ec039ec..a51a21e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vitest/config'; +import { version } from './package.json'; export default defineConfig({ + // Mirrors esbuild's --define in the build script, so tests run the same code path as the bundle. + define: { + __PACKAGE_VERSION__: JSON.stringify(version) + }, test: { include: ['tests/**/*.test.ts'], - clearMocks: true, reporters: ['verbose', ['junit', { outputFile: 'reports/junit.xml' }]], coverage: { provider: 'v8',