Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
519fc04
Add code coverage script
colemancda Jul 18, 2026
4188d6e
Upload code coverage to Codecov and GitHub Code Quality in CI
colemancda Jul 18, 2026
dd98c37
Merge coverage from all test binaries in coverage script
colemancda Jul 18, 2026
8da82ed
Fix key path inverse relationship name in @Entity macro expansion
colemancda Jul 18, 2026
e8502ec
Add CoreModelMacrosTests test target
colemancda Jul 18, 2026
40a2db6
Add EntityMacro expansion tests
colemancda Jul 18, 2026
2288f70
Add attribute and relationship coding tests
colemancda Jul 18, 2026
e793e91
Apply fetch offset when converting FetchRequest to NSFetchRequest
colemancda Jul 18, 2026
3e3f476
Add persistent storage and attribute type round-trip tests
colemancda Jul 18, 2026
99ff27d
Add predicate, key path, and string comparison tests
colemancda Jul 18, 2026
71289e7
Add model, entity, and NSNumber conversion tests
colemancda Jul 18, 2026
d04c651
Add in-memory function evaluation and sorting tests
colemancda Jul 18, 2026
f9767ef
Raise CI coverage threshold to 90%
colemancda Jul 18, 2026
ede48ea
Add CoreData model conversion and context storage tests
colemancda Jul 18, 2026
ea7f2c6
Add ModelStorage default implementation tests
colemancda Jul 18, 2026
0ba705a
Cover mixed nil operand equality in function evaluation tests
colemancda Jul 18, 2026
3fecaf7
Cover unrelated property attributes in EntityMacro tests
colemancda Jul 18, 2026
f4cfa36
Restrict macro expansion tests to macOS and Linux
colemancda Jul 18, 2026
8a195c6
Fix coverage script crash on bash 3.2 with single test bundle
colemancda Jul 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ jobs:
- name: Test
run: ${{ matrix.options }} swift test -c ${{ matrix.config }}

coverage:
name: Code Coverage
runs-on: macos-26
permissions:
contents: read
code-quality: write # required by actions/upload-code-coverage
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Swift Version
run: swift --version
- name: Export coverage and enforce threshold
run: Scripts/coverage.sh 90
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: .build/coverage/coverage.lcov
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload coverage to GitHub Code Quality
uses: actions/upload-code-coverage@v1
with:
file: .build/coverage/coverage.xml
language: Swift
label: code-coverage/llvm-cov
# Code Quality was in public preview until 2026-07-20; keep this while
# any target repo might not have the feature enabled, so a failed upload
# doesn't break CI. Remove once every consumer is on GA.
fail-on-error: false
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: code-coverage
path: .build/coverage/
if-no-files-found: error

linux:
name: Linux (${{ matrix.container }})
runs-on: ubuntu-latest
Expand Down
24 changes: 24 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,28 @@ if enableMacros {
package.targets[0].dependencies += [
"CoreModelMacros"
]
package.targets += [
.testTarget(
name: "CoreModelMacrosTests",
dependencies: [
"CoreModelMacros",
.product(
name: "SwiftSyntaxMacros",
package: "swift-syntax"
),
.product(
name: "SwiftSyntaxMacroExpansion",
package: "swift-syntax"
),
.product(
name: "SwiftParser",
package: "swift-syntax"
),
.product(
name: "SwiftSyntaxMacrosTestSupport",
package: "swift-syntax"
)
]
)
]
}
221 changes: 221 additions & 0 deletions Scripts/coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
#
# coverage.sh — run a SwiftPM test suite with code coverage, export LCOV and
# Cobertura reports, and enforce a minimum line-coverage threshold.
#
# Coverage is measured against the package's OWN sources only (everything under
# the repo's `Sources/` directory). Dependencies (checked out under `.build`),
# generated sources, and the test target are excluded, so the number reflects
# the coverage of this package's code rather than being diluted by third-party
# code that the tests happen to exercise.
#
# The Cobertura XML (coverage.xml) is the format GitHub Code Quality consumes via
# `actions/upload-code-coverage`; the LCOV report (coverage.lcov) is kept for
# other tools (Codecov, Coveralls, editors) that prefer it.
#
# Usage:
# Scripts/coverage.sh [threshold]
#
# Environment variables:
# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 80).
# Overridden by the optional [threshold] argument.
# COVERAGE_SOURCE_PREFIX Absolute path prefix selecting the sources that count
# toward coverage (default: "$PWD/Sources/"). Narrow it
# to a single target, e.g. "$PWD/Sources/MyLibrary/", to
# gate on one target instead of every source file.
# COVERAGE_OUTPUT LCOV output file (default: .build/coverage/coverage.lcov).
# COBERTURA_OUTPUT Cobertura XML output file (default: .build/coverage/coverage.xml).
# SKIP_TEST If set to 1, reuse existing coverage data instead of
# re-running `swift test` (useful while iterating).

set -euo pipefail

THRESHOLD="${1:-${COVERAGE_THRESHOLD:-80}}"
SOURCE_PREFIX="${COVERAGE_SOURCE_PREFIX:-$PWD/Sources/}"
OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}"
COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}"

# Resolve the correct llvm-cov (xcrun on Apple platforms, plain llvm-cov elsewhere).
if command -v xcrun >/dev/null 2>&1; then
LLVM_COV="xcrun llvm-cov"
else
LLVM_COV="llvm-cov"
fi

# 1. Run the tests with coverage instrumentation.
if [ "${SKIP_TEST:-0}" != "1" ]; then
echo "==> Running tests with code coverage"
swift test --enable-code-coverage
fi

# 2. Locate the coverage artifacts SwiftPM produced.
CODECOV_JSON="$(swift test --enable-code-coverage --show-codecov-path)"
COV_DIR="$(dirname "$CODECOV_JSON")"
PROFDATA="$COV_DIR/default.profdata"

if [ ! -f "$CODECOV_JSON" ]; then
echo "error: coverage report not found at $CODECOV_JSON" >&2
exit 1
fi

# 3. Locate the instrumented test binary (differs by platform: on macOS it lives
# inside the .xctest bundle, on Linux the .xctest file is the binary itself).
BIN_PATH="$(swift build --show-bin-path)"
TEST_BINARIES=()
for candidate in \
"$BIN_PATH"/*Tests.xctest/Contents/MacOS/*Tests \
"$BIN_PATH"/*Tests.xctest; do
if [ -f "$candidate" ]; then
TEST_BINARIES+=("$candidate")
fi
done

# llvm-cov takes the first binary as a positional argument and the rest via
# -object. On macOS/Linux SwiftPM normally merges every test target into one
# `<Package>PackageTests` bundle, so there's usually a single binary and
# OBJECT_ARGS stays empty; the extra binaries only appear in multi-product
# setups. The `${arr[@]+…}` guards below keep empty-array expansions from
# tripping `set -u` on the macOS system bash (3.2), where "${empty[@]}" is
# otherwise treated as an unbound variable.
OBJECT_ARGS=()
if [ "${#TEST_BINARIES[@]}" -gt 1 ]; then
for bin in "${TEST_BINARIES[@]:1}"; do
OBJECT_ARGS+=(-object "$bin")
done
fi

# 4. Export an LCOV report (for Codecov / Coveralls / editors).
mkdir -p "$(dirname "$OUTPUT")"
if [ "${#TEST_BINARIES[@]}" -gt 0 ] && [ -f "$PROFDATA" ]; then
echo "==> Exporting LCOV report to $OUTPUT"
$LLVM_COV export \
-format=lcov \
-instr-profile "$PROFDATA" \
"${TEST_BINARIES[0]}" ${OBJECT_ARGS[@]+"${OBJECT_ARGS[@]}"} \
-ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \
> "$OUTPUT"
# SwiftPM's own codecov JSON only reflects a single test binary when the
# package has multiple test products, so export a merged JSON ourselves and
# use it for the Cobertura report and the threshold below.
echo "==> Exporting merged llvm-cov JSON"
$LLVM_COV export \
-format=text \
-instr-profile "$PROFDATA" \
"${TEST_BINARIES[0]}" ${OBJECT_ARGS[@]+"${OBJECT_ARGS[@]}"} \
-ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \
> "$(dirname "$OUTPUT")/coverage.json"
CODECOV_JSON="$(dirname "$OUTPUT")/coverage.json"
else
echo "warning: could not locate test binaries or profdata; skipping LCOV export" >&2
fi

# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage).
echo "==> Writing Cobertura report to $COBERTURA_OUTPUT"
mkdir -p "$(dirname "$COBERTURA_OUTPUT")"
python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY'
import json, os, sys, time
from xml.sax.saxutils import escape, quoteattr

report_path, source_prefix, repo_root, output = sys.argv[1:5]

with open(report_path) as f:
report = json.load(f)

files = []
total_covered = total_lines = 0
for file in report["data"][0]["files"]:
name = file["filename"]
if not name.startswith(source_prefix):
continue
# Per-line hit count: the greatest region count starting on each line (a line
# is covered if any region on it executed, so branch sub-regions don't hide it).
line_hits = {}
for seg in file["segments"]:
line, count, has_count = seg[0], seg[2], seg[3]
if has_count:
line_hits[line] = max(line_hits.get(line, 0), count)
covered = sum(1 for h in line_hits.values() if h > 0)
total_covered += covered
total_lines += len(line_hits)
files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits)))

def rate(c, t):
return c / t if t else 0.0

overall = rate(total_covered, total_lines)
timestamp = int(time.time())

out = [
'<?xml version="1.0" ?>',
'<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">',
'<coverage line-rate="%.4f" branch-rate="0" lines-covered="%d" lines-valid="%d" '
'branches-covered="0" branches-valid="0" complexity="0" version="llvm-cov" timestamp="%d">'
% (overall, total_covered, total_lines, timestamp),
" <sources>",
" <source>%s</source>" % escape(repo_root),
" </sources>",
" <packages>",
' <package name="%s" line-rate="%.4f" branch-rate="0" complexity="0">'
% (escape(os.path.basename(repo_root)), overall),
" <classes>",
]
for rel, line_hits, covered, total in sorted(files):
out.append(
' <class name=%s filename=%s line-rate="%.4f" branch-rate="0" complexity="0">'
% (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total))
)
out.append(" <methods/>")
out.append(" <lines>")
for line in sorted(line_hits):
out.append(' <line number="%d" hits="%d"/>' % (line, line_hits[line]))
out.append(" </lines>")
out.append(" </class>")
out += [" </classes>", " </package>", " </packages>", "</coverage>"]

with open(output, "w") as f:
f.write("\n".join(out) + "\n")

print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall))
PY

# 6. Compute line coverage for the package sources and enforce the threshold.
echo "==> Computing coverage for ${SOURCE_PREFIX}"
python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY'
import json, os, sys

report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4]

with open(report_path) as f:
report = json.load(f)

covered = total = 0
rows = []
for file in report["data"][0]["files"]:
name = file["filename"]
if not name.startswith(source_prefix):
continue
lines = file["summary"]["lines"]
covered += lines["covered"]
total += lines["count"]
rows.append((lines["percent"], os.path.relpath(name, repo_root)))

if total == 0:
print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr)
print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr)
sys.exit(1)

percent = 100.0 * covered / total

for pct, name in sorted(rows):
print(" %6.2f%% %s" % (pct, name))

print("-" * 48)
print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total))
print("Required threshold: %.2f%%" % threshold)

if percent < threshold:
print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr)
sys.exit(1)

print("PASSED: coverage meets the threshold")
PY
1 change: 1 addition & 0 deletions Sources/CoreDataModel/NSFetchRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public extension FetchRequest {
let fetchRequest = NSFetchRequest<ResultType>(entityName: entity.rawValue)
fetchRequest.predicate = predicate?.toFoundation()
fetchRequest.fetchLimit = fetchLimit
fetchRequest.fetchOffset = fetchOffset
var sortDescriptors = sortDescriptors.compactMap { sort -> NSSortDescriptor? in
guard let property = sort.property else {
// Function-based sort terms are not supported by NSFetchRequest;
Expand Down
2 changes: 1 addition & 1 deletion Sources/CoreModelMacros/Entity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ extension EntityMacro {
inverseKeyName = memberAccess.declName.baseName.text
} else if let keyPathExpr = inverseArg.expression.as(KeyPathExprSyntax.self),
let lastComponent = keyPathExpr.components.last {
inverseKeyName = lastComponent.description
inverseKeyName = lastComponent.component.trimmedDescription
} else {
throw MacroError.unknownInverseRelationship(for: identifier)
}
Expand Down
Loading
Loading