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
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ ownership boundaries so behavior changes remain reviewable and testable.

### Workspaces

`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, and 1024 members. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths.
`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths.

`cdidx workspace use <name>` writes the active workspace to the per-user config directory. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery.
`cdidx workspace use <name>` writes an existing manifest member or `default` workspace to the per-user config directory, rejects missing manifest members, and rejects ambiguous member directory names. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery.

### Observability

Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3041.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3041
affected:
- src/CodeIndex/Cli/WorkspaceManifest.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Workspace manifests now reject unknown `index_strategy` values (#3041)** - typos such as `singel` now fail manifest validation instead of silently using per-member DB behavior.

## 日本語

- **Workspace manifest が未知の `index_strategy` 値を拒否するようになりました (#3041)** - `singel` のような typo は per-member DB 動作へ黙って落ちず、manifest 検証エラーになります。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3165.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3165
affected:
- src/CodeIndex/Cli/WorkspaceCommandRunner.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **`workspace use` now rejects ambiguous same-basename members (#3165)** - when multiple manifest members share the requested directory name, the command now reports a bounded candidate list instead of selecting the first match.

## 日本語

- **`workspace use` が同じ basename の曖昧な member を拒否するようになりました (#3165)** - 要求された directory name に一致する manifest member が複数ある場合、先頭を選ばず bounded な候補一覧を含むエラーを返します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3216.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3216
affected:
- src/CodeIndex/Cli/WorkspaceManifest.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Workspace manifests now reject overlong member paths and default DB names (#3216)** - manifest loading now bounds member path strings and `default_db_name` before resolving paths or materializing DB locations.

## 日本語

- **Workspace manifest が長すぎる member path と default DB 名を拒否するようになりました (#3216)** - manifest 読み込み時に path 解決や DB 位置の materialize 前に member path 文字列と `default_db_name` の長さを制限します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3217.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3217
affected:
- src/CodeIndex/Cli/WorkspaceCommandRunner.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **`workspace use` now rejects missing manifest members (#3217)** - selecting a listed member that is absent on disk now fails with a usage error instead of persisting an active workspace pointing at the missing root.

## 日本語

- **`workspace use` が存在しない manifest member を拒否するようになりました (#3217)** - manifest にある member がディスク上に存在しない場合、missing root を指す active workspace を保存せず usage error を返します。
40 changes: 35 additions & 5 deletions src/CodeIndex/Cli/WorkspaceCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace CodeIndex.Cli;

internal static class WorkspaceCommandRunner
{
private const int MaxAmbiguousMemberCandidates = 5;
private const int MaxAmbiguousMemberPathChars = 160;

internal static int Run(string[] args, JsonSerializerOptions jsonOptions)
{
var json = args.Contains("--json", StringComparer.Ordinal);
Expand Down Expand Up @@ -69,11 +72,24 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio
if (manifest == null && !useDefault)
return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace manifest was not found.", CommandExitCodes.UsageError, "run `cdidx workspace use <name>` from a manifest member or pass `default`.");

var member = useDefault
? null
: manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase));
if (manifest != null && member == null && !useDefault)
return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names.");
WorkspaceMember? member = null;
if (manifest != null && !useDefault)
{
var matches = manifest.Members
.Where(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase))
.Take(MaxAmbiguousMemberCandidates + 1)
.ToArray();

if (matches.Length == 0)
return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names.");
if (matches.Length > 1)
return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member name is ambiguous.", CommandExitCodes.UsageError, $"matching members: {FormatAmbiguousMemberCandidates(matches)}. Use unique member directory names in the workspace manifest.");

member = matches[0];
}

if (member is { Exists: false })
return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member is missing on disk.", CommandExitCodes.UsageError, "create the missing member directory or run `cdidx workspace list` and choose an existing member.");

var root = member?.Path ?? Environment.CurrentDirectory;
var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null);
Expand All @@ -85,4 +101,18 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio
Console.WriteLine($"Active workspace set to {state.Name}: {state.DbPath}");
return CommandExitCodes.Success;
}

private static string FormatAmbiguousMemberCandidates(IReadOnlyList<WorkspaceMember> matches)
{
var candidates = matches
.Take(MaxAmbiguousMemberCandidates)
.Select(member => TruncateAmbiguousMemberPath(member.Path));
var suffix = matches.Count > MaxAmbiguousMemberCandidates ? ", ..." : string.Empty;
return string.Join(", ", candidates) + suffix;
}

private static string TruncateAmbiguousMemberPath(string path)
=> path.Length <= MaxAmbiguousMemberPathChars
? path
: path[..(MaxAmbiguousMemberPathChars - 3)] + "...";
}
21 changes: 20 additions & 1 deletion src/CodeIndex/Cli/WorkspaceManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ internal static class WorkspaceManifestLoader
internal const int MaxManifestBytes = 64 * 1024;
internal const int MaxManifestDepth = 16;
internal const int MaxManifestMembers = 1024;
internal const int MaxManifestMemberPathChars = 4096;
internal const int MaxDefaultDbNameChars = 255;

internal static WorkspaceManifest? Find(string startingDirectory)
{
Expand Down Expand Up @@ -71,7 +73,7 @@ internal static WorkspaceManifest Load(string path)
});

var element = document.RootElement;
var strategy = ReadString(element, "index_strategy") ?? "per_member";
var strategy = ValidateIndexStrategy(ReadString(element, "index_strategy") ?? "per_member");
var dbName = ValidateDefaultDbName(ReadString(element, "default_db_name") ?? "codeindex.db");
var rawMembers = ReadMembers(element);

Expand All @@ -92,8 +94,22 @@ internal static WorkspaceManifest Load(string path)
? value.GetString()
: null;

private static string ValidateIndexStrategy(string strategy)
{
if (string.Equals(strategy, "per_member", StringComparison.OrdinalIgnoreCase)
|| string.Equals(strategy, "single", StringComparison.OrdinalIgnoreCase))
{
return strategy;
}

throw new InvalidDataException($"Workspace manifest index_strategy must be 'per_member' or 'single': {strategy}");
}

private static string ValidateDefaultDbName(string dbName)
{
if (dbName.Length > MaxDefaultDbNameChars)
throw new InvalidDataException($"Workspace manifest default_db_name exceeds the {MaxDefaultDbNameChars} character limit.");

if (string.IsNullOrWhiteSpace(dbName)
|| dbName is "." or ".."
|| Path.IsPathRooted(dbName)
Expand Down Expand Up @@ -123,6 +139,9 @@ private static IReadOnlyList<string> ReadMembers(JsonElement element)
if (string.IsNullOrWhiteSpace(value))
continue;

if (value.Length > MaxManifestMemberPathChars)
throw new InvalidDataException($"Workspace manifest member path exceeds the {MaxManifestMemberPathChars} character limit.");

if (members.Count >= MaxManifestMembers)
throw new InvalidDataException($"Workspace manifest members exceed the {MaxManifestMembers} member limit.");

Expand Down
142 changes: 142 additions & 0 deletions tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,30 @@ public void WorkspaceManifestLoader_Load_RejectsTooManyMembers()
}
}

[Fact]
public void WorkspaceManifestLoader_Load_RejectsOverlongMemberPath()
{
var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_member_length");
try
{
var manifestPath = Path.Combine(root, "cdidx.workspace.json");
var member = new string('a', WorkspaceManifestLoader.MaxManifestMemberPathChars + 1);
File.WriteAllText(manifestPath, $$"""
{
"members": [{{JsonSerializer.Serialize(member)}}]
}
""");

var ex = Assert.Throws<InvalidDataException>(() => WorkspaceManifestLoader.Load(manifestPath));

Assert.Contains($"{WorkspaceManifestLoader.MaxManifestMemberPathChars} character limit", ex.Message);
}
finally
{
TestProjectHelper.DeleteDirectory(root);
}
}

[Fact]
public void WorkspaceManifestLoader_Load_RejectsRootedMemberPath()
{
Expand Down Expand Up @@ -153,6 +177,30 @@ public void WorkspaceManifestLoader_Load_RejectsEscapingMemberPath()
}
}

[Fact]
public void WorkspaceManifestLoader_Load_RejectsOverlongDefaultDbName()
{
var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_db_name_length");
try
{
var manifestPath = Path.Combine(root, "cdidx.workspace.json");
var dbName = new string('a', WorkspaceManifestLoader.MaxDefaultDbNameChars + 1);
File.WriteAllText(manifestPath, $$"""
{
"default_db_name": {{JsonSerializer.Serialize(dbName)}}
}
""");

var ex = Assert.Throws<InvalidDataException>(() => WorkspaceManifestLoader.Load(manifestPath));

Assert.Contains($"{WorkspaceManifestLoader.MaxDefaultDbNameChars} character limit", ex.Message);
}
finally
{
TestProjectHelper.DeleteDirectory(root);
}
}

[Fact]
public void WorkspaceManifestLoader_Load_RejectsAbsoluteDefaultDbName()
{
Expand Down Expand Up @@ -230,6 +278,30 @@ public void WorkspaceManifestLoader_Load_AcceptsUtf8BomManifest()
}
}

[Fact]
public void WorkspaceManifestLoader_Load_RejectsUnknownIndexStrategy()
{
var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_index_strategy");
try
{
var manifestPath = Path.Combine(root, "cdidx.workspace.json");
File.WriteAllText(manifestPath, """
{
"members": ["src/A"],
"index_strategy": "singel"
}
""");

var ex = Assert.Throws<InvalidDataException>(() => WorkspaceManifestLoader.Load(manifestPath));

Assert.Contains("index_strategy must be 'per_member' or 'single'", ex.Message);
}
finally
{
TestProjectHelper.DeleteDirectory(root);
}
}

[Fact]
public void WorkspaceErrors_HonorJsonFlag()
{
Expand Down Expand Up @@ -531,6 +603,76 @@ public void WorkspaceUse_RejectsUnknownManifestMember()
}
}

[Fact]
public void WorkspaceUse_RejectsMissingManifestMember()
{
var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing");
var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing_config");
try
{
File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/Missing"] }""");
using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME");
Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome);

var previous = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = root;
var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "Missing"], _jsonOptions));

Assert.Equal(CommandExitCodes.UsageError, exitCode);
Assert.Contains("workspace member is missing on disk", stderr);
Assert.False(File.Exists(ActiveWorkspace.StatePath));
}
finally
{
Environment.CurrentDirectory = previous;
}
}
finally
{
TestProjectHelper.DeleteDirectory(root);
TestProjectHelper.DeleteDirectory(configHome);
}
}

[Fact]
public void WorkspaceUse_RejectsAmbiguousSameBasenameMembers()
{
var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous");
var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous_config");
try
{
Directory.CreateDirectory(Path.Combine(root, "src", "App"));
Directory.CreateDirectory(Path.Combine(root, "tests", "App"));
File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/App", "tests/App"] }""");
using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME");
Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome);

var previous = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = root;
var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "App"], _jsonOptions));

Assert.Equal(CommandExitCodes.UsageError, exitCode);
Assert.Contains("workspace member name is ambiguous", stderr);
Assert.Contains(Path.Combine("src", "App"), stderr);
Assert.Contains(Path.Combine("tests", "App"), stderr);
Assert.False(File.Exists(ActiveWorkspace.StatePath));
}
finally
{
Environment.CurrentDirectory = previous;
}
}
finally
{
TestProjectHelper.DeleteDirectory(root);
TestProjectHelper.DeleteDirectory(configHome);
}
}

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