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
35 changes: 35 additions & 0 deletions .github/workflows/mutation-testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Mutation testing

on:
schedule:
- cron: "17 3 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
dbwriter:
name: DbWriter mutation testing
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x

- name: Restore
run: dotnet restore CodeIndex.sln --locked-mode

- name: Install Stryker.NET
run: dotnet tool install --global dotnet-stryker

- name: Run DbWriter mutation tests
run: dotnet stryker --config-file stryker-config.json
17 changes: 17 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ failures from pass-on-retry flakes. For test suite structure, shared helpers,
state-isolation rules, timeout diagnostics, and test-writing conventions, see
[TESTING_GUIDE.md](TESTING_GUIDE.md).

The weekly `Mutation testing` workflow runs Stryker.NET against
`src/CodeIndex/Database/DbWriter.cs` using `stryker-config.json`. Keep this
scope focused on transaction, savepoint, rollback, and batch-write behavior
unless the runtime budget is intentionally expanded. The mutation score gates
are high 75, low 70, and break 65 so changes that weaken rollback or savepoint
coverage fail outside the regular PR test path.

## CI / Artifact Distribution

Query commands accept `--read-only` (alias `--immutable`) to open an existing
Expand Down Expand Up @@ -150,6 +157,16 @@ Incremental refreshes that mutate `fts_chunks` increment `codeindex_meta.fts_inc

Successful writer sessions attempt `PRAGMA wal_checkpoint(TRUNCATE)` before closing a writable `DbContext`, so large WAL files are reclaimed after index, backfill, optimize, prune, and other DB-writing commands. `cdidx db schema [--json]` dumps `sqlite_master` entries plus `PRAGMA user_version` for schema inspection, and `cdidx db prune --dry-run|--apply [--json]` counts or deletes orphaned `symbol_references`, `reference_lines`, and `symbols` rows before running `PRAGMA optimize` on apply.

### Metadata invariants

`DbWriter.SetMeta` participates in the caller's writer transaction when one is
active. When no writer transaction is active, it wraps the metadata UPSERT in a
SQLite savepoint so standalone stamps still have a commit boundary and calls
from raw SQL transactions do not attempt a nested `BEGIN`. Dependent metadata
and row rewrites that must succeed or fail together should be placed inside the
same `DbWriter.BeginTransaction()` scope; do not stamp readiness or schema
trust metadata before the dependent rows are written.

### Extending the indexer

Out-of-tree post-extraction hooks can implement `CodeIndex.Indexer.Hooks.IPostExtractionHook` in a `.dll` placed under `~/.config/cdidx/hooks/` (or the directory named by `CDIDX_HOOKS_DIR`). Hook assemblies are discovered in path order. Each concrete hook type is instantiated with a public parameterless constructor, then called after built-in symbol extraction and again after built-in reference extraction, before rows are persisted. Hooks receive a `FileContext` plus mutable `IList<SymbolRecord>` / `IList<ReferenceRecord>` values, so they can annotate extracted records, add synthetic symbols, or add domain-specific references.
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1669.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: internal
issues:
- 1669
affected:
- stryker-config.json
- .github/workflows/mutation-testing.yml
- DEVELOPER_GUIDE.md
---

## English

- **Added weekly DbWriter mutation testing (#1669)** — Stryker.NET now runs against the DbWriter transaction and rollback surface on a scheduled workflow with documented score gates.

## 日本語

- **DbWriter の週次 mutation testing を追加しました (#1669)** — Stryker.NET が DbWriter の transaction / rollback 周辺を定期 workflow で検査し、score gate も文書化されました。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1735.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 1735
affected:
- src/CodeIndex/Database/DbWriter.cs
- tests/CodeIndex.Tests/PreparedCommandCacheTests.cs
---

## English

- **Locked in atomic unchanged-file reuse (#1735)** — `GetUnchangedFileId` now has regression coverage ensuring checksum drift does not touch stale file metadata.

## 日本語

- **未変更ファイル再利用の atomic 契約を固定しました (#1735)** — `GetUnchangedFileId` に checksum drift 時に古い file metadata を touch しない regression coverage を追加しました。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1753.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 1753
affected:
- src/CodeIndex/Database/DbWriter.cs
- tests/CodeIndex.Tests/DatabaseTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Made metadata stamps participate in transaction boundaries (#1753)** — `SetMeta` now joins writer transactions or uses a SQLite savepoint for standalone writes so metadata and dependent rows can roll back together.

## 日本語

- **metadata stamp が transaction 境界に参加するようにしました (#1753)** — `SetMeta` は writer transaction に参加し、単独書き込みでは SQLite savepoint を使うため、metadata と依存 row をまとめて rollback できます。
25 changes: 25 additions & 0 deletions src/CodeIndex/Database/DbWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ private void RunPassiveWalCheckpoint()
if (!SymbolExtractorVersionMatchesCurrent(language))
return null;

// Keep the unchanged check and timestamp touch in one SQLite statement so
// concurrent row drift cannot slip between a SELECT and a later UPDATE (#1735).
var cmd = RentCommand(
@"UPDATE files
SET modified = CASE
Expand Down Expand Up @@ -3023,7 +3025,30 @@ public void SetMeta(string key, string? value)
if (!HasMetaTable())
return;

if (!IsInTransaction())
{
Execute("SAVEPOINT set_meta_atomic");
try
{
SetMetaCore(key, value);
Execute("RELEASE SAVEPOINT set_meta_atomic");
}
catch
{
try { Execute("ROLLBACK TO SAVEPOINT set_meta_atomic"); } catch (SqliteException) { /* best effort */ }
try { Execute("RELEASE SAVEPOINT set_meta_atomic"); } catch (SqliteException) { /* best effort */ }
throw;
}
return;
}

SetMetaCore(key, value);
}

private void SetMetaCore(string key, string? value)
{
using var cmd = _conn.CreateCommand();
cmd.Transaction = _activeTransaction;
cmd.CommandText = @"INSERT INTO codeindex_meta (key, value) VALUES (@key, @value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value";
cmd.Parameters.AddWithValue("@key", key);
Expand Down
22 changes: 22 additions & 0 deletions stryker-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"stryker-config": {
"solution": "CodeIndex.sln",
"project": "src/CodeIndex/CodeIndex.csproj",
"test-projects": [
"tests/CodeIndex.Tests/CodeIndex.Tests.csproj"
],
"mutate": [
"src/CodeIndex/Database/DbWriter.cs"
],
"thresholds": {
"high": 75,
"low": 70,
"break": 65
},
"reporters": [
"progress",
"cleartext",
"html"
]
}
}
47 changes: 47 additions & 0 deletions tests/CodeIndex.Tests/DatabaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2429,6 +2429,45 @@ public void DbContext_NewDatabaseRestrictsFileModeOnPosix()
Assert.Equal("0600", DbContext.GetUnixFileModeString(_dbPath));
}

[Fact]
public void SetMeta_InsideWriterTransaction_RollsBackWithDependentRows_Issue1753()
{
using (var transaction = _writer.BeginTransaction())
{
_writer.SetMeta("schema_phase", "new");
_writer.UpsertFile(new FileRecord
{
Path = "src/partial.cs",
Lang = "csharp",
Size = 12,
Lines = 1,
Modified = new DateTime(2026, 5, 31, 0, 0, 0, DateTimeKind.Utc),
Checksum = "partial",
});
}

Assert.Null(ReadMeta("schema_phase"));
Assert.False(_writer.HasFileAtPath("src/partial.cs"));
}

[Fact]
public void SetMeta_InsideRawSqlTransaction_UsesSavepointWithoutNestedBegin_Issue1753()
{
ExecuteNonQuery(_db.Connection, "BEGIN IMMEDIATE");
try
{
_writer.SetMeta("raw_phase", "new");
ExecuteNonQuery(_db.Connection, "ROLLBACK");
}
catch
{
ExecuteNonQuery(_db.Connection, "ROLLBACK");
throw;
}

Assert.Null(ReadMeta("raw_phase"));
}

private void DeleteDbPath()
{
DeleteDbFiles(_dbPath);
Expand Down Expand Up @@ -2476,6 +2515,14 @@ private string ExecuteScalarString(string sql)
private long ExecuteScalarLong(string sql)
=> ExecuteScalarLong(_db.Connection, sql);

private string? ReadMeta(string key)
{
using var cmd = _db.Connection.CreateCommand();
cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key";
cmd.Parameters.AddWithValue("@key", key);
return cmd.ExecuteScalar() as string;
}

private static long ExecuteScalarLong(SqliteConnection connection, string sql)
{
using var cmd = connection.CreateCommand();
Expand Down
24 changes: 24 additions & 0 deletions tests/CodeIndex.Tests/PreparedCommandCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,30 @@ public void DbWriter_WithCache_GetUnchangedFileIdTouchUpdatesTimestamp()
Assert.Equal(touched, reader.GetDateTime(0));
}

[Fact]
public void DbWriter_WithCache_GetUnchangedFileIdDoesNotTouchWhenChecksumDrifts_Issue1735()
{
var writer = new DbWriter(_db);
var initial = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var touched = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc);

writer.UpsertFile(new FileRecord
{
Path = "src/drift.py", Lang = "python", Size = 1, Lines = 1,
Checksum = "old_checksum", Modified = initial,
});

Assert.Null(writer.GetUnchangedFileId("src/drift.py", touched, "new_checksum"));

using var cmd = _db.Connection.CreateCommand();
cmd.CommandText = "SELECT modified, checksum FROM files WHERE path = @p";
cmd.Parameters.AddWithValue("@p", "src/drift.py");
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal(initial, reader.GetDateTime(0));
Assert.Equal("old_checksum", reader.GetString(1));
}

[Fact]
public void DbReader_WithCache_ReusesCSharpResolutionCommandsAcrossReaders()
{
Expand Down
Loading