Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
"dotnet-reportgenerator-globaltool": {
"version": "5.5.10",
"commands": ["reportgenerator"]
},
"dotnet-stryker": {
"version": "5.0.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}
11 changes: 11 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ try {
Invoke-CheckedCommand npm @('run', 'lint') $sitePath
Invoke-CheckedCommand npm @('run', 'typecheck') $sitePath
Invoke-CheckedCommand npm @('run', 'test:coverage') $sitePath

# Match the measured scopes and thresholds required by the Quality workflow.
$mutationRunner = Join-Path $repoRoot 'scripts/Run-MutationTests.ps1'
$mutationRoot = Join-Path $coverageRoot 'mutation'
& $mutationRunner -Project Core -BreakAt 90 -ArtifactsPath $mutationRoot
& $mutationRunner -Project Cli -BreakAt 55 -ArtifactsPath $mutationRoot -Mutate @(
'**/Commands/SyncCommand.cs',
'**/Commands/CatalogCommand.cs',
'**/Sync/ExtractionProgressDisplay.cs'
)
Invoke-CheckedCommand npm @('run', 'test:mutation', '--', 'stryker.ci.config.mjs') $sitePath
}
finally {
Write-Host "Coverage reports: $coverageRoot"
Expand Down
78 changes: 78 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Mutation testing

on:
workflow_dispatch:
schedule:
- cron: "41 7 * * 1"

permissions:
contents: read

concurrency:
group: mutation-${{ github.ref }}
cancel-in-progress: true

jobs:
cli:
name: CLI mutation (${{ matrix.project }})
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
max-parallel: 2
matrix:
project:
[
Core,
Catalog,
Cli,
Extraction.MsSql,
Extraction.Oracle,
Lineage.MsSql,
Lineage.Oracle,
]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Run mutation tests
shell: pwsh
env:
MUTATION_PROJECT: ${{ matrix.project }}
run: ./scripts/Run-MutationTests.ps1 -Project $env:MUTATION_PROJECT
- name: Upload mutation reports
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-cli-${{ matrix.project }}
path: TestResults/mutation/
if-no-files-found: error
retention-days: 14

site:
name: Site mutation
runs-on: ubuntu-latest
timeout-minutes: 120
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm ci
- run: npm run test:mutation
- name: Upload mutation reports
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-site
path: site/reports/mutation/
if-no-files-found: error
retention-days: 14
74 changes: 72 additions & 2 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,74 @@ jobs:
if-no-files-found: error
retention-days: 14

cli-mutation:
name: CLI mutation (${{ matrix.project }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- project: Core
minimum: 90
mutate: []
- project: Cli
minimum: 55
mutate:
- '**/Commands/SyncCommand.cs'
- '**/Commands/CatalogCommand.cs'
- '**/Sync/ExtractionProgressDisplay.cs'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Enforce measured mutation baseline
shell: pwsh
env:
MUTATION_PROJECT: ${{ matrix.project }}
MUTATION_MINIMUM: ${{ matrix.minimum }}
MUTATION_PATTERNS: ${{ toJSON(matrix.mutate) }}
run: |
$patterns = @(ConvertFrom-Json $env:MUTATION_PATTERNS)
./scripts/Run-MutationTests.ps1 -Project $env:MUTATION_PROJECT -BreakAt ([int]$env:MUTATION_MINIMUM) -Mutate $patterns -ArtifactsPath TestResults/quality-mutation
- name: Upload mutation reports
if: always()
uses: actions/upload-artifact@v4
with:
name: quality-mutation-cli-${{ matrix.project }}
path: TestResults/quality-mutation/
if-no-files-found: error
retention-days: 14

site-mutation:
name: Site reachability mutation
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm ci
- name: Enforce reachability mutation baseline
run: npm run test:mutation -- stryker.ci.config.mjs
- name: Upload mutation reports
if: always()
uses: actions/upload-artifact@v4
with:
name: quality-mutation-site
path: site/reports/mutation/
if-no-files-found: error
retention-days: 14

cli-performance:
name: CLI performance budgets
runs-on: ubuntu-latest
Expand Down Expand Up @@ -308,7 +376,7 @@ jobs:
quality-gate:
name: Quality gate
if: always()
needs: [vulnerabilities, codeql, cli-style, cli-coverage, cli-performance, site-style, site-coverage, workflow-lint, coverage-badges]
needs: [vulnerabilities, codeql, cli-style, cli-coverage, cli-mutation, cli-performance, site-style, site-coverage, site-mutation, workflow-lint, coverage-badges]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand Down Expand Up @@ -343,7 +411,7 @@ jobs:

quality-comment:
name: Comment quality results
needs: [vulnerabilities, codeql, cli-style, cli-coverage, cli-performance, site-style, site-coverage, workflow-lint, coverage-badges, quality-gate]
needs: [vulnerabilities, codeql, cli-style, cli-coverage, cli-mutation, cli-performance, site-style, site-coverage, site-mutation, workflow-lint, coverage-badges, quality-gate]
# Fork PR tokens are read-only; keep their checks running without attempting
# to elevate permissions or making a missing comment fail their gate.
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
Expand All @@ -368,9 +436,11 @@ jobs:
| CodeQL | ${{ needs.codeql.result }} |
| CLI lint and format | ${{ needs.cli-style.result }} |
| CLI tests and coverage | ${{ needs.cli-coverage.result }} |
| CLI mutation (Core ≥90%, selected command files ≥55%) | ${{ needs.cli-mutation.result }} |
| CLI performance budgets | ${{ needs.cli-performance.result }} |
| Site lint and format | ${{ needs.site-style.result }} |
| Site tests and coverage | ${{ needs.site-coverage.result }} |
| Site reachability mutation (100%) | ${{ needs.site-mutation.result }} |
| Workflow lint | ${{ needs.workflow-lint.result }} |
| Coverage badge data | ${{ needs.coverage-badges.result }} |

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ site/node_modules/
site/dist/
site/*.tsbuildinfo
coverage/
StrykerOutput/
.stryker-tmp/
site/reports/mutation/

# .NET CLI build output
cli/**/bin/
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,8 @@ GitHub workflows are grouped into independent CLI and Site families:

| Workflow | What it runs |
|----------|--------------|
| [Quality](.github/workflows/quality.yml) | CodeQL, dependency review and full npm/NuGet audits, CLI/Site lint and format checks, coverage thresholds, workflow lint, and a single aggregate quality gate. |
| [Quality](.github/workflows/quality.yml) | CodeQL, dependency review and full npm/NuGet audits, CLI/Site lint and format checks, coverage and mutation thresholds, workflow lint, and a single aggregate quality gate. |
| [Mutation testing](.github/workflows/mutation.yml) | Weekly/manual Stryker runs for CLI projects and the site, with HTML/JSON reports. See [local commands and score policy](docs/mutation-testing.md). |
| [CLI - CI](.github/workflows/cli.yml) | .NET format, build, tests and coverage on Linux and Windows; gateway publishing guards and CLI workflow validation; publishing-script compatibility checks, including the actual Windows PowerShell 5.1 parser. |
| [CLI - benchmark](.github/workflows/cli-benchmark.yml) | Provisions the sample databases, extracts objects and verifies lineage on relevant pull requests or manual runs. Manual runs can also enable the prepared Oracle gateway runner. |
| [Docker - publish Oracle gateway to GHCR](.github/workflows/cli-publish-oracle-gateway.yml) | Manually validates and publishes the private gateway image from the default branch. |
Expand Down
6 changes: 2 additions & 4 deletions cli/src/SyncSql.Cli/Commands/SyncCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ public static Command Build(IServiceProvider services)
}

attemptedServers++;
DatabaseCredentials credentials;
try
{
credentials = ResolveCredentials(server, credentialProvider, OperatingSystem.IsWindows());
DatabaseCredentials credentials = ResolveCredentials(server, credentialProvider, OperatingSystem.IsWindows());
work.Add((server, depth, filters, credentials));
}
catch (InvalidOperationException ex)
{
Expand All @@ -224,8 +224,6 @@ public static Command Build(IServiceProvider services)
(depth == 0 ? failedServers : failedDiscoveredServers).Add(server.Name);
continue;
}

work.Add((server, depth, filters, credentials));
}

// Each worker owns its connections and output. Fold the small summaries in input
Expand Down
2 changes: 1 addition & 1 deletion cli/src/SyncSql.Cli/Sync/ExtractionProgressDisplay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public CatalogProgressDisplay StartCatalog(Action<string>? log = null)
{
lock (Gate)
{
CatalogProgressDisplay display = new(this, log ?? (message => WriteLog(message + Environment.NewLine)));
CatalogProgressDisplay display = new(this, log ?? (message => WriteLog($"{message}{Environment.NewLine}")));
_display = display;
if (animated)
{
Expand Down
12 changes: 9 additions & 3 deletions cli/src/SyncSql.Core/Abstractions/ExtractionWorkScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ public sealed class ExtractionWorkScheduler
private readonly Dictionary<DatabaseEngine, int> _active = [];
private int _running;
private int _nextEngine;
private readonly TaskScheduler _taskScheduler;

public ExtractionWorkScheduler(int maxParallelism)
public ExtractionWorkScheduler(int maxParallelism) : this(maxParallelism, TaskScheduler.Default)
{
}

internal ExtractionWorkScheduler(int maxParallelism, TaskScheduler taskScheduler)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxParallelism, 1);
MaxParallelism = maxParallelism;
_taskScheduler = taskScheduler;
}

public int MaxParallelism { get; }
Expand Down Expand Up @@ -88,7 +94,7 @@ private void Dispatch()
_running++;
_nextEngine = (selected + 1) % engines.Length;
ExtractionWorkContext context = new(this, engine, item.Token);
_ = Task.Run(async () =>
_ = Task.Factory.StartNew(async () =>
{
try { await item.ExecuteAsync(context); }
finally
Expand All @@ -99,7 +105,7 @@ private void Dispatch()
Dispatch();
}
}
}, CancellationToken.None);
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, _taskScheduler).Unwrap();
}
}

Expand Down
25 changes: 14 additions & 11 deletions cli/src/SyncSql.Core/Configuration/SyncSqlConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,7 @@ public static async Task<SyncSqlConfig> LoadAsync(string path, CancellationToken
}

await using FileStream stream = File.OpenRead(path);
SyncSqlConfig config;
try
{
config = await JsonSerializer.DeserializeAsync<SyncSqlConfig>(stream, SerializerOptions, cancellationToken)
?? throw new ConfigValidationException($"Config file '{path}' is empty or 'null'.");
}
catch (JsonException ex)
{
throw new ConfigValidationException($"Config file '{path}' could not be parsed: {ex.Message}");
}

SyncSqlConfig config = await DeserializeAsync(stream, path, cancellationToken);
Validate(config, path);
string directory = Path.GetDirectoryName(Path.GetFullPath(path))!;
return config with
Expand All @@ -51,6 +41,19 @@ public static async Task<SyncSqlConfig> LoadAsync(string path, CancellationToken
string? ResolvePath(string? value) => value is null ? null : Path.GetFullPath(value, directory);
}

private static async Task<SyncSqlConfig> DeserializeAsync(Stream stream, string path, CancellationToken cancellationToken)
{
try
{
return await JsonSerializer.DeserializeAsync<SyncSqlConfig>(stream, SerializerOptions, cancellationToken)
?? throw new ConfigValidationException($"Config file '{path}' is empty or 'null'.");
}
catch (JsonException ex)
{
throw new ConfigValidationException($"Config file '{path}' could not be parsed: {ex.Message}");
}
}

private static void Validate(SyncSqlConfig config, string path)
{
if (config.Servers.Count == 0)
Expand Down
31 changes: 15 additions & 16 deletions cli/src/SyncSql.Core/Credentials/CredentialsFileProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,30 +45,29 @@ public static async Task<CredentialsFileProvider> LoadAsync(string path, Cancell
throw new CredentialParseException($"Credentials file not found: {path}");
}

Dictionary<string, CredentialsFileEntry?>? entries;
Dictionary<string, CredentialsFileEntry?> entries = await ReadEntriesAsync(path, cancellationToken);
Dictionary<string, PartialCredentials> byPrefix = new(StringComparer.OrdinalIgnoreCase);
foreach ((string prefix, CredentialsFileEntry? entry) in entries)
{
byPrefix[prefix] = new PartialCredentials(entry?.User, entry?.Password);
}

return new CredentialsFileProvider(byPrefix, path);
}

private static async Task<Dictionary<string, CredentialsFileEntry?>> ReadEntriesAsync(string path, CancellationToken cancellationToken)
{
await using FileStream stream = File.OpenRead(path);
try
{
await using FileStream stream = File.OpenRead(path);
entries = await JsonSerializer.DeserializeAsync<Dictionary<string, CredentialsFileEntry?>>(stream, SerializerOptions, cancellationToken);
return await JsonSerializer.DeserializeAsync<Dictionary<string, CredentialsFileEntry?>>(stream, SerializerOptions, cancellationToken)
?? throw new CredentialParseException($"Credentials file '{path}' is empty or 'null'.");
}
catch (JsonException ex)
{
throw new CredentialParseException(
$"Credentials file '{path}' could not be parsed: {ex.Message} Expected {{ \"PREFIX\": {{ \"user\": \"...\", \"password\": \"...\" }} }}.");
}

if (entries is null)
{
throw new CredentialParseException($"Credentials file '{path}' is empty or 'null'.");
}

Dictionary<string, PartialCredentials> byPrefix = new(StringComparer.OrdinalIgnoreCase);
foreach ((string prefix, CredentialsFileEntry? entry) in entries)
{
byPrefix[prefix] = new PartialCredentials(entry?.User, entry?.Password);
}

return new CredentialsFileProvider(byPrefix, path);
}

public PartialCredentials Read(string credentialsVariablePrefix) =>
Expand Down
10 changes: 10 additions & 0 deletions cli/stryker-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"stryker-config": {
"mutation-level": "Standard",
"reporters": ["progress", "html", "json"],
"concurrency": 2,
"thresholds": { "high": 80, "low": 60, "break": 0 },
"break-on-initial-test-failure": true,
"mutate": ["**/*.cs", "!**/obj/**", "!**/bin/**", "!**/Generated/**"]
}
}
Loading
Loading