Skip to content

feat(package): declarative package management for rbt - #35

Merged
vgvgvvv merged 10 commits into
mainfrom
claude/package-management-implementation-oszbcs
Jul 28, 2026
Merged

feat(package): declarative package management for rbt#35
vgvgvvv merged 10 commits into
mainfrom
claude/package-management-implementation-oszbcs

Conversation

@vgvgvvv

@vgvgvvv vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner

给 rbt 加一套包管理:声明式清单 + lock 文件 + 传递依赖解析 + 四种来源 + 三种包形态。

为什么是这个设计

一个顺序约束决定了整体结构:CppBuildProject.InitAllRule()Assembly.LoadFile 加载 CompileRules.dll,一次性且不可卸载。所以包里的 .module.cs 必须在 ParseRules() glob 之前就落到磁盘上——不存在「先编译规则、再发现依赖、再补进同一个程序集」这条路。由此:

  • restore 跑在 Parse() 里、ParseRules() 之前;
  • 清单是 JSON 而非 C#——传递依赖解析要反复读取「尚未下载的包」的清单,文件读取轻而易举,「编译 + 加载」循环做不到。

这也解释了 CppTargetRule.GitLibraries 为何从来没工作过:它挂在 target rule 上,只有规则程序集编译完才读得到。已标 [Obsolete] 并在文档中说明迁移方式。

内容

清单 / lock RBTPackage.json + RBTPackage.lock.json(记录 tag 实际解析到的 commit,应当提交)
来源 git、HTTP 压缩包(zip / tar.gz / tar)、本地路径、vcpkg
包形态 源码包自带规则;预编译二进制包(合成规则);上游原样源码 + overlay
版本 精确 pin + 传递依赖;pin 冲突和依赖成环都是硬错误,冲突提示 overrides 出口
CLI --Mode Restore--Offline--ForceRestore--UpdateLock--PackageAdd--PackageRemove
{
  "dependencies": {
    "GreeterLib": { "git": "https://github.com/x/greeter.git", "tag": "v1.2.0" },
    "zlib":       { "url": "https://.../zlib-1.3.tar.gz", "sha256": "", "strip": 1 },
    "LocalLib":   { "path": "../LocalLib" },
    "fmt":        { "vcpkg": "fmt", "triplet": "x64-windows" }
  }
}

几个判断点

  • Packages/ 放项目根目录而非 Intermedia/:后者会被 Clean() 清空,且 CleanIfNeed() 在 rbt 二进制更新时自行触发,放那里等于每次 rebuild 都重新下载全部依赖。
  • 二进制包生成 .module.cs,而不是直接注册 IModuleInterface:这样 InitAllRuleModuleRulePaths 查找、SetupInternal 生命周期、_API 宏代码生成、四种 IDE 生成器和 HeaderTool 插件全部原样继续工作。生成的文件不内联当前平台,产物由 PackageArtifactSelectorSetup 时按 buildContext 选择——否则切一次 --TargetPlatform 文件内容就变,会连带重编全部规则。
  • overlay 复制进包根目录:它的相对 SourceDirectories / ExcludeFiles 要解析到上游代码树。
  • 全程复用 WriteIfChanged 语义(沿用 ModuleRule.GenCode.cs 的既有先例),避免时间戳抖动触发无谓重编。
  • 压缩包解压拒绝任何逃逸出目标目录的条目(zip slip),并经暂存目录原子换入。
  • 没有 RBTPackage.json 的项目完全不受影响:不建 Packages/、不写 lock、不动 .gitignore

没有做的部分

  1. 全局包缓存(~/.rbt/PackageCache)未实现。git 包直接 clone 进 Packages/<name> 并保留 .git,更新走 fetch + checkout。这样更简单、不会出现多项目共享 checkout 互相打架,代价是多个项目用同一依赖会各自 clone 一份。属于纯优化,可后补。
  2. vcpkg 的实际安装路径无法离线测试(需要 clone + bootstrap + 联网数分钟)。有决策含量的部分——安装树 → 二进制包清单的映射,尤其 vcpkg 的 debug/release 拆分——已单独抽出并测试;真正调用 vcpkg 子进程的那一段只能靠实际使用验证。
  3. vcpkg triplet 默认取宿主机。restore 必然发生在构建上下文存在之前,交叉编译时需要在清单里显式写 "triplet"

测试

新增 59 个测试(41 → 100),全部离线 —— CI 跑三个平台,网络依赖的测试就是不稳定源:

  • git 用例在临时目录 git init 造真仓库,通过文件路径 clone;
  • HTTP 用例用 loopback HttpListener 提供测试自己压出来的包;
  • vcpkg 用例针对伪造的安装树验证映射;
  • 两个集成测试用真实工具链走完 restore → 规则编译 → 编译链接(一个消费二进制包,一个通过 overlay 构建上游源码);
  • Sample/PackageConsumer 通过路径依赖消费 Sample/GeometryPackage,已加入原有样例构建套件,在三个平台上真实编译链接。

本地结果:97 通过、2 跳过(Windows-only)、1 失败 —— TestHeaderToolCodegen。该失败已用 git stash 在改动前的原始树上复现,与本 PR 无关(既有问题)。

顺带

修掉 Doc/ARCH.md 中已过时的 ReBuildTool.Ini 条目(该项目已不存在)。EN + zh-CN 四份文档均已更新。


Generated by Claude Code

Copilot AI review requested due to automatic review settings July 28, 2026 13:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a declarative package management layer for rbt, adding a JSON manifest + lockfile workflow, transitive dependency resolution, multiple fetch sources, and integration into the existing rule-compilation pipeline so package modules participate like local modules.

Changes:

  • Add RBTPackage.json / RBTPackage.lock.json manifest+lock support with a DFS resolver, conflict/cycle detection, and fetchers (git / HTTP archives / path / vcpkg).
  • Integrate restore into CppBuildProject.Parse() and synthesize/overlay module rules for binary and upstream-source package shapes.
  • Add extensive offline test coverage plus new sample projects and updated docs/README.

Reviewed changes

Copilot reviewed 50 out of 50 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
Sample/PackageConsumer/Source/PackageConsumerTarget.target.cs New sample target consuming a module that depends on a restored package module.
Sample/PackageConsumer/Source/AppModule/Public/AppModule.h Sample app header.
Sample/PackageConsumer/Source/AppModule/Private/AppModule.cpp Sample app main demonstrating usage of a package-provided header/module.
Sample/PackageConsumer/Source/AppModule/AppModule.module.cs Sample module rule depending on a package module by name.
Sample/PackageConsumer/RBTPackage.lock.json Sample lockfile demonstrating path dependency pinning.
Sample/PackageConsumer/RBTPackage.json Sample manifest declaring a path dependency.
Sample/PackageConsumer/global.json Sample .NET SDK pin for the new sample project.
Sample/PackageConsumer/.gitignore Sample ignore file excluding build outputs and /Packages/.
Sample/GeometryPackage/RBTPackage.json Sample package manifest for a distributable package.
Sample/GeometryPackage/Public/GeometryModule.h Sample package public header with exported API macro usage.
Sample/GeometryPackage/Private/GeometryModule.cpp Sample package implementation.
Sample/GeometryPackage/GeometryModule.module.cs Sample package module rule shipped inside the package.
ReBuildTool/ReBuildTool/Program.cs Adds RunMode.Restore dispatch behavior.
ReBuildTool/ReBuildTool.Test/TestPackageVcpkg.cs Tests vcpkg install-tree → binary-manifest mapping behavior.
ReBuildTool/ReBuildTool.Test/TestPackageRestore.cs End-to-end offline git restore tests using local repos.
ReBuildTool/ReBuildTool.Test/TestPackageResolver.cs Resolver graph-walk tests (transitive, diamond, cycles, conflicts, overrides).
ReBuildTool/ReBuildTool.Test/TestPackageManifestEditor.cs Tests for --PackageAdd/--PackageRemove spec parsing and JSON-preserving edits.
ReBuildTool/ReBuildTool.Test/TestPackageManifest.cs Manifest validation + lockfile roundtrip/write-if-changed tests.
ReBuildTool/ReBuildTool.Test/TestPackageBuildIntegration.cs Integration tests proving restored packages reach rule compilation and toolchain builds.
ReBuildTool/ReBuildTool.Test/TestPackageBinaryModule.cs Tests for synthesized module rules and artifact selection for binary packages + overlays.
ReBuildTool/ReBuildTool.Test/TestPackageArchive.cs Tests archive download/unpack + zip-slip defense + offline re-restore behavior.
ReBuildTool/ReBuildTool.Test/TestCppBuild.cs Adds Sample/PackageConsumer to the sample-build matrix.
ReBuildTool/ReBuildTool.Service/PackageService/ProcessRunner.cs New helper to run external tools and capture stdout/stderr for package operations.
ReBuildTool/ReBuildTool.Service/PackageService/PackageRestoreService.cs Reads manifest, resolves/fetches packages under Packages/, writes lock, updates .gitignore.
ReBuildTool/ReBuildTool.Service/PackageService/PackageResolver.cs DFS dependency resolution, conflict/cycle detection, lock production, overlay resolution.
ReBuildTool/ReBuildTool.Service/PackageService/PackageManifestEditor.cs CLI manifest editing that preserves unknown JSON fields.
ReBuildTool/ReBuildTool.Service/PackageService/PackageManifest.cs Manifest/dep models, pin identity computation, binary-package spec schema, validation.
ReBuildTool/ReBuildTool.Service/PackageService/PackageLock.cs Lockfile schema + read/ignore-future-version + write-if-changed behavior.
ReBuildTool/ReBuildTool.Service/PackageService/IPackageService.cs Package service interface, restore options, restored-package model/result.
ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/VcpkgPackageFetcher.cs vcpkg bridge: bootstrap/install + generate binary-package manifest for installed tree.
ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/PathPackageFetcher.cs Path dependency fetcher (use-in-place, absolute resolved path in resolved).
ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/IPackageFetcher.cs Fetcher interface and fetch request/result models.
ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs HTTP archive downloader + checksum + atomic unpack + offline behavior.
ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/GitPackageFetcher.cs Git clone/fetch/reset + tag/branch re-resolve (--UpdateLock) + offline behavior.
ReBuildTool/ReBuildTool.Service/Context/ServiceContext.Default.cs Registers IPackageService default implementation in the service context.
ReBuildTool/ReBuildTool.Service/CompileService/CppCompile.cs Marks legacy GitLibraries API obsolete in favor of manifests.
ReBuildTool/ReBuildTool.Service/CommandGroup/ICommonCommandGroup.cs Adds RunMode.Restore CLI mode.
ReBuildTool/ReBuildTool.CppCompiler/Project/PackageArgs.cs Adds restore/edit CLI flags (--Offline, --ForceRestore, --UpdateLock, etc.).
ReBuildTool/ReBuildTool.CppCompiler/Project/CppBuildProject.cs Runs restore before rule glob/compile; globs package rule roots; improves module collision error.
ReBuildTool/ReBuildTool.CppCompiler/Package/PackageModuleBinder.cs Generates .module.cs for binary packages and installs consumer overlays into package roots.
ReBuildTool/ReBuildTool.CppCompiler/Package/PackageArtifactSelector.cs Selects appropriate prebuilt artifacts at Setup() time based on platform/arch/config.
ReBuildTool/ReBuildTool.CppCompiler/Common/CppTargetRule.cs Marks GitLibraries obsolete at target-rule level.
ReBuildTool/ReBuildTool.Common/Misc/Hashing.cs Adds sha256 hashing + tolerant comparison helper.
ReBuildTool/ReBuildTool.Common/Misc/Downloader.cs Adds minimal HTTP download helper with atomic .partial handling.
ReBuildTool/ReBuildTool.Common/Misc/ArchiveExtractor.cs Adds zip/tar/tgz extraction with strip-components and zip-slip prevention.
README.md Mentions package management support at a high level.
Doc/HowToUse.zh-CN.md Documents package management workflow, shapes, lock semantics, and flags; deprecates GitLibraries.
Doc/HowToUse.md English documentation for package management and flags; deprecates GitLibraries.
Doc/ARCH.zh-CN.md Architecture docs updated (package pipeline, rationale, and removed Ini frontend note).
Doc/ARCH.md Architecture docs updated (package pipeline, rationale, and removed Ini frontend note).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 185 to 189
public void Parse()
{
RestorePackages();
ParseRules();
}
Comment on lines +179 to +183
var bootstrap = VcpkgRoot.Combine(
PlatformHelper.IsWindows() ? "bootstrap-vcpkg.bat" : "bootstrap-vcpkg.sh");
Log.Info("[package] bootstrapping vcpkg");
ProcessRunner.RunOrThrow(bootstrap.ToString(), Array.Empty<string>(), VcpkgRoot, "bootstrapping vcpkg");
}
Comment on lines +76 to +79
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();

Comment on lines +117 to +124
return ResolveKind(packageName) switch
{
PackageSourceKind.Git => $"git:{Git}@{GitRevision}",
PackageSourceKind.HttpArchive => $"url:{Url}@{Sha256}",
PackageSourceKind.Path => $"path:{Path}",
PackageSourceKind.Vcpkg => $"vcpkg:{Vcpkg}@{Version}:{Triplet}",
_ => throw new PackageException($"unknown source kind for package \"{packageName}\"")
};
Copilot AI review requested due to automatic review settings July 28, 2026 14:01

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Pushed 48c3fac, which fixes the Windows CI failure plus three of the four review points.

Windows CI failure (3 tests, one root cause). TestSampleProjectBuild("PackageConsumer"), ABinaryPackageIsGeneratedIntoTheBuildAndCompiles and AnOverlayRuleBuildsUpstreamSources all died in VCProject.GenerateModule with ArgumentException: You are attempting an operation that is not valid on a root level directory.

The filter walk climbs from a module directory up until it finds one named Source. Only a module living under Source/ ever reaches that sentinel — a package's module sits under Packages/, and a package consumed through a path dependency isn't under the project at all — so the walk ran off the top of the tree and NPath.FileName threw. It now stops at the project root and the filesystem root, and a package outside the project is grouped under a Modules/ filter by its own directory name rather than named relative to the project (which would have produced a ..\..\ filter Solution Explorer can't display).

This only ever fired on Windows because every other host defaults to the CMake generator, which is why it passed locally and on the Linux/macOS legs. The new TestPackageVsFilters forces VS generation explicitly, so it now runs on all three — verified it fails without the fix and passes with it.

--Mode Restore did more than restore — correct, and worse than described: Parse() also scaffolds a default Target/Module when a project has none, so --Mode Restore could write source files into a bare checkout. IProjectInterface gains Restore() and Program.cs dispatches to it directly. Covered by RestoreDoesNotCompileRulesOrScaffoldAProject, which asserts neither Source/ nor Intermedia/ appears.

bootstrap-vcpkg.bat under UseShellExecute = false — correct, it would have failed on every Windows host. Now runs through cmd.exe /c.

vcpkg triplet in PinKey — correct. { "vcpkg": "fmt" } and { "vcpkg": "fmt", "triplet": "<host>" } produced different pin keys and would have been reported as conflicting on the very machine where they're identical. EffectiveTriplet resolves the host default before comparison, so the lock's pin also names the triplet that was actually built.

ProcessRunner.WaitForExit() truncating output — this one I'm not changing, and I think the concern doesn't apply here. The parameterless WaitForExit() is specifically the overload that waits for the asynchronous output handlers to drain; it's the timeout overload WaitForExit(int) that returns without them, and the docs call this out explicitly: "This overload ensures that all processing has been completed, including the handling of asynchronous events for redirected standard output. You should use this overload after a call to the WaitForExit(Int32) overload when standard output has been redirected to asynchronous event handlers." ProcessRunner only ever calls the parameterless form, so captured output is complete. Happy to revisit if you've seen it truncate in practice.

Local run after the fixes: 100 passed, 2 skipped (Windows-only), 1 failed — TestHeaderToolCodegen, which fails identically on the unmodified tree (verified by stashing this branch's changes) and passed on the Windows CI leg. Unrelated to this PR.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 53 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (3)

ReBuildTool/ReBuildTool.CppCompiler/Project/CppBuildProject.cs:609

  • XML doc comment references RestorePackages, but the method introduced in this PR is named Restore(). This breaks the cref target (and can fail doc builds when warnings are treated as errors).
	/// <summary>Packages materialized by the last <see cref="RestorePackages"/>, in dependency order.</summary>
	private List<RestoredPackage> RestoredPackages { get; } = new();

ReBuildTool/ReBuildTool.Service/PackageService/PackageLock.cs:21

  • Doc comment says a path dependency's resolved value is an absolute path, but PathPackageFetcher intentionally returns the declared (portable) path as the resolved string so the lock can be committed and shared. The comment should match the actual semantics.
	/// <summary>Commit sha for git, content sha256 for an archive, the absolute path for a path dependency.</summary>
	[JsonProperty("resolved")] public string? Resolved { get; set; }

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/IPackageFetcher.cs:53

  • FetchedPackage.Resolved is documented as "an absolute path" for path dependencies, but the fetchers use this string as the lock/identity value and PathPackageFetcher deliberately returns the declared path (so it stays shareable across machines). Update the comment to avoid misleading API consumers.
	/// <summary>Where the package content lives. For a path dependency this is outside Packages/.</summary>
	public NPath Root { get; }

	/// <summary>What the pin actually resolved to: a commit sha, an archive hash, or an absolute path.</summary>
	public string Resolved { get; }

Comment on lines +86 to +110
private readonly HttpListener Listener;

public LocalServer(NPath file)
{
// Port 0 is not available through HttpListener, so probe upward for a free one.
for (var port = 18800; port < 18900; port++)
{
var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
try
{
listener.Start();
Listener = listener;
Url = $"http://127.0.0.1:{port}/{file.FileName}";
break;
}
catch (HttpListenerException)
{
listener.Close();
}
}
if (Listener == null)
{
throw new InvalidOperationException("no free loopback port for the test server");
}
Copilot AI review requested due to automatic review settings July 28, 2026 14:13

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

CI is green on 48c3fac — all three test legs (Linux, Windows, macOS) and all four publish jobs pass. The Windows filter crash is resolved.

Worth noting: the Linux leg passes TestHeaderToolCodegen too. That test fails in my local sandbox because the network policy there blocks the host it clones the header tool from — so it was environmental, not a repo problem. Nothing outstanding from it.

Pushed 11cda15 for the second review round — three comments that no longer matched their code:

  • RestoredPackages' cref still pointed at RestorePackages, which I renamed to Restore() in the previous commit, so it no longer resolved.
  • PackageLock.Resolved and FetchedPackage.Resolved both claimed a path dependency records an absolute path. It records the path as declared — deliberately, since the lock is committed and an absolute path would be meaningless in any other checkout. Both now spell out what the field holds per source.

On LocalServer: the CS0171 prediction doesn't hold — definite assignment isn't enforced for class fields, only struct fields, which is why it compiled and ran. But the shape was genuinely misleading (assigning a readonly field inside a probe loop, then null-checking a non-nullable field), so the probe now goes through a local and both fields are assigned once.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 53 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

ReBuildTool/ReBuildTool.Service/PackageService/ProcessRunner.cs:79

  • BeginOutputReadLine/BeginErrorReadLine are asynchronous; WaitForExit() can return before the output callbacks have fully drained, which can truncate captured stdout/stderr (especially on failures where you rely on StdErr). Add a second wait (or explicitly wait for stream completion) before returning the captured buffers.
		process.BeginOutputReadLine();
		process.BeginErrorReadLine();
		process.WaitForExit();

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs:39

  • When sha256 is omitted, the cached-directory fast path always returns success (expected == null), even if the manifest URL changed since the last restore. That can leave a stale package on disk while producing a new lock entry (Origin/Pin updated, content not). The cache hit should be tied to the current lock/pin (or the URL) when sha256 is not provided.
		{
			var current = stamp.ReadAllText().Trim();
			if (expected == null || Hashing.Matches(expected, current))
			{
                return new FetchedPackage(destination, current);

/// <summary>The matching lock entry, when the project already has one.</summary>
public LockedPackage? Locked { get; }

public NPath DefaultDestination => PackagesRoot.Combine(Name);
Comment on lines +56 to +57
var moduleName = string.IsNullOrWhiteSpace(binary.Module) ? package.Name : binary.Module!;
var moduleDirectory = packagesRoot.Combine(GeneratedFolderName, moduleName);
Copilot AI review requested due to automatic review settings July 28, 2026 14:30

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Pushed 9ebd44c for the third review round. Two of these were good catches on the same underlying mistake — trusting a manifest further than it deserves. A package fetched from a remote declares its own transitive dependencies, so names arriving from it are exactly as trustworthy as the package is, and rbt turns them into filesystem paths and into generated C# it then compiles and runs.

Package name path traversal — real. A dependency keyed "../outside" would have placed the package next to Packages/ rather than inside it, and the archive fetcher's sidecar stamp had the same hole. Names are now validated before being combined into a path. The check lives in the resolver so it covers every name in the walk, not just the ones in the project's own manifest, and FetchRequest re-applies it plus an explicit containment assert for any future caller that builds a request directly. Covered by APackageNameThatEscapesThePackagesDirectoryIsRejected (../outside, .., nested/name, back\slash) with a companion case pinning that ordinary names — dots, dashes, underscores, leading digits — still work.

Module name injection — real, and the more serious of the two. The name is interpolated into public class <name> in a rule that becomes part of CompileRules.dll, which rbt executes as part of the build, so punctuation could have closed the declaration and appended arbitrary code. Now validated as a plain C# identifier and rejected rather than escaped — a package has no business naming a class outside its own either way. The test includes the actual injection shape, Evil { } public class Injected : CppModuleRule { //.

Archive cache with no sha256 — real. Any stamp satisfied that path, so editing a dependency's url left the previous archive unpacked while the lock recorded the new origin: stale content under a fresh-looking pin. The stamp now carries the origin URL alongside the hash, and the no-checksum fast path compares it. A stamp written by an older rbt reports an empty URL, so it simply misses once and re-downloads. ChangingTheUrlWithNoChecksumStillReplacesTheContent serves two different archives from two loopback ports and asserts the old file is gone and the new one present.

ProcessRunner.WaitForExit() — still not changing this one; same reasoning as before. The parameterless overload is the one that waits for the async output handlers to drain — that's precisely the documented difference between it and WaitForExit(Int32), and ProcessRunner only ever calls the parameterless form.

Local: 113 passed, 2 skipped (Windows-only), 1 failed — TestHeaderToolCodegen, which is my sandbox blocking the host it clones from and passes on the Linux CI leg.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 53 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

ReBuildTool/ReBuildTool.Service/PackageService/PackageManifest.cs:134

  • For vcpkg dependencies, PinKey currently formats as vcpkg:<port>@<Version>:<triplet>. Since Version is typically unset (and isn’t used by VcpkgPackageFetcher), this produces pins like vcpkg:fmt@:x64-windows, which is confusing in lock files and in conflict errors.
		return ResolveKind(packageName) switch
		{
			PackageSourceKind.Git => $"git:{Git}@{GitRevision}",
			PackageSourceKind.HttpArchive => $"url:{Url}@{Sha256}",
			PackageSourceKind.Path => $"path:{Path}",
			PackageSourceKind.Vcpkg => $"vcpkg:{Vcpkg}@{Version}:{EffectiveTriplet}",
			_ => throw new PackageException($"unknown source kind for package \"{packageName}\"")

ReBuildTool/ReBuildTool.Service/PackageService/PackageResolver.cs:72

  • The returned PackageRestoreResult is not “deepest-first”: packages are added to Resolved before their dependencies are walked (see Resolved[name] = ... before recursing), so Resolved.Values enumerates in first-seen order. The current comment is misleading and suggests ordering guarantees that aren’t actually provided.
		// Deepest-first: a package is listed after everything it needed, which is the order a
		// reader wants and costs nothing to produce here.
		return new PackageRestoreResult(Resolved.Values.Select(entry => entry.Package).ToList());

Comment on lines +60 to +67
var moduleName = PackageNames.ValidateModuleName(
string.IsNullOrWhiteSpace(binary.Module) ? package.Name : binary.Module!,
package.Name);
var moduleDirectory = packagesRoot.Combine(GeneratedFolderName, moduleName);
// The framework unconditionally registers a module's Public/ and Private/ directories; the
// source globbing warns once per missing path, so create them rather than emit noise.
moduleDirectory.Combine("Public").EnsureDirectoryExists();
moduleDirectory.Combine("Private").EnsureDirectoryExists();
Copilot AI review requested due to automatic review settings July 28, 2026 14:40

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Pushed 01b3312 for the fourth round — all three correct.

Two binary packages claiming one module name — real, and silent, which is the worst part. Both generate to Packages/.generated/<module>/<module>.module.cs, the same file, so the second write simply won and the build depended on processing order with nothing to indicate it. The binder now tracks generated names and reports both culprits. Worth noting the asymmetry that made this easy to miss: the same clash between two source packages was already caught in ParseRules, because there they are two distinct files claiming one name — this path had to catch its own.

vcpkg:fmt@:x64-windows — real, and it pointed at something worse than cosmetics. Version was never read by anything; which version of a port you get is decided by the vcpkg checkout, which the fetcher pins to a fixed tag. Real per-port pinning would need vcpkg manifest mode and a versioning baseline this bridge doesn't set up. Rather than tidy the formatting around an inert field, I removed it — a knob that silently does nothing is a trap. Pins now read vcpkg:<port>:<triplet>.

"Deepest-first" was wrong — correct, a package is recorded before its dependencies are walked, so it precedes them. I checked whether anything actually wanted topological order before changing the comment rather than the code: nothing does, the packages become rule-glob roots and the lock is sorted by name on write. So the comment now states what holds instead of promising a guarantee that was never there.

Local: 116 passed, 2 skipped (Windows-only), 1 failed — TestHeaderToolCodegen, the sandbox network restriction that passes on CI.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 53 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

ReBuildTool/ReBuildTool.Service/PackageService/ProcessRunner.cs:45

  • ProcessRunner.Run() silently ignores a non-null workingDirectory when it does not exist, which can cause commands to run in an unexpected directory and make failures very hard to diagnose. If a caller provided a working directory, treat a missing directory as an error.
		if (workingDirectory != null && workingDirectory.DirectoryExists())
		{
			startInfo.WorkingDirectory = workingDirectory.ToString();
		}

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/GitPackageFetcher.cs:130

  • TryResolve() passes a manifest-controlled revision string to git rev-parse without an end-of-options marker. While ArgumentList prevents shell injection, git still parses leading - as options; adding -- avoids option-injection edge cases and makes the call robust.
		var result = ProcessRunner.Run(
			"git",
			new[] { "rev-parse", "--verify", "--quiet", $"{reference}^{{commit}}" },
			repository);

Comment on lines +90 to +93
private static string Resolve(NPath packageRoot, string path)
{
return System.IO.Path.IsPathRooted(path) ? path : packageRoot.Combine(path).ToString();
}
Comment on lines +36 to +40
ProcessRunner.RunOrThrow(
"git",
new[] { "clone", "--recurse-submodules", url, destination.ToString() },
null,
$"cloning package \"{request.Name}\"");
Copilot AI review requested due to automatic review settings July 28, 2026 14:50

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Pushed 602d236 for the fifth round — four more, all correct, and all the same shape: a manifest reaching further than it should.

git option injection via URL and revision — real. ArgumentList rules out shell injection but git still reads a leading - as an option, so --upload-pack=… in a manifest would have become a git flag. Both are now validated alongside the rest of the manifest validation — once, rather than at each site that shells out. I went with rejecting the shape rather than --end-of-options: the marker needs git 2.24, and a validation rule works on any version. git clone also gets an explicit -- so it doesn't depend on that check staying in place. Covered with the actual --upload-pack= payload for both URL and revision, plus a case pinning that ordinary URLs and tags like v1.2.0-rc.1 still pass.

Relative artifact paths escaping the package — real. ../.. in a binary package's includes/libraryDirectories would have put arbitrary directories of the consuming machine on the include or library search path. Relative entries must now resolve inside the package.

One thing worth flagging on that: I deliberately did not extend this to absolute paths, even though they're equally unconstrained in principle. Absolute entries are exactly what the vcpkg bridge emits — a vcpkg installed tree lives outside Packages/ by design — so rejecting them would break that path entirely. There's a test pinning that absolute entries still pass through, so the distinction is intentional and won't get "tidied up" later by accident.

ProcessRunner ignoring a missing working directory — real, and nastier than it looks: the tool ran in rbt's own working directory instead, so a git command would silently target the wrong repository and fail in a way that gives no hint why. Now an error. ReBuildTool.Service gains InternalsVisibleTo for the test project — same as ReBuildTool.CppCompiler already has — so the runner is covered directly, including that a null working directory stays legal (cloning happens before the destination exists).

Local: 129 passed, 2 skipped (Windows-only), 1 failed — TestHeaderToolCodegen, the sandbox network restriction that passes on CI.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

ReBuildTool/ReBuildTool.Service/PackageService/ProcessRunner.cs:87

  • ProcessRunner.Run uses async BeginOutputReadLine/BeginErrorReadLine but returns immediately after a single WaitForExit(). It’s possible for the process to exit before the async readers have drained their final callbacks, which can truncate captured stdout/stderr (and make diagnostics flaky). Add a second WaitForExit() (or otherwise wait for stream completion) before returning the captured output.
		process.BeginOutputReadLine();
		process.BeginErrorReadLine();
		process.WaitForExit();

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/VcpkgPackageFetcher.cs:221

  • DefaultTriplet() currently infers only x86/x64 via Environment.Is64BitOperatingSystem, so on ARM64 hosts it will still return an x64 triplet (e.g. x64-osx). That breaks the intended “default to host triplet” behavior on Apple Silicon / ARM Linux. Use RuntimeInformation.OSArchitecture (or equivalent) to map arm64/arm as well, and consider throwing a PackageException for unknown architectures so the user is forced to set "triplet" explicitly.
	public static string DefaultTriplet()
	{
		var architecture = Environment.Is64BitOperatingSystem ? "x64" : "x86";
		if (PlatformHelper.IsWindows())
		{

ReBuildTool/ReBuildTool.Test/TestPackageVcpkg.cs:149

  • This test currently asserts the default triplet is only x86/x64, but the repo claims arm64 host support. Once DefaultTriplet() is fixed to return arm64-* on ARM64 machines, this assertion will fail on those hosts. Expand the regex (or make it architecture-agnostic) so the test reflects the intended behavior.
    ReBuildTool/ReBuildTool.Service/PackageService/PackageManifestEditor.cs:21
  • ParseSpec’s docstring and error message describe url:<href>#<sha256> and vcpkg:<port>#<triplet> as if the #... qualifier is always required, but the implementation allows it to be omitted (URL checksum is optional; vcpkg triplet defaults to host). The message also says git:<url>#<tag> even though commit qualifiers are supported. Updating the documentation/error text will prevent confusing UX.
	/// Parses the compact spec accepted by <c>--PackageAdd</c>:
	/// <c>git:&lt;url&gt;#&lt;tag-or-commit&gt;</c>, <c>path:&lt;dir&gt;</c>,
	/// <c>url:&lt;href&gt;#&lt;sha256&gt;</c> or <c>vcpkg:&lt;port&gt;#&lt;triplet&gt;</c>.
	/// </summary>

ReBuildTool/ReBuildTool.Service/PackageService/PackageManifestEditor.cs:30

  • The exception text for malformed specs still implies the #... qualifiers are mandatory for url/vcpkg, and that git pins are tag-only. Since the parser accepts url:<href> (no sha) and vcpkg:<port> (no triplet), and git accepts tag-or-commit, the error should reflect the actual grammar (e.g. url:<href>[#<sha256>], vcpkg:<port>[#<triplet>], git:<url>#<tag-or-commit>).
			throw new PackageException(
				$"cannot read package spec \"{spec}\": expected one of " +
				$"git:<url>#<tag>, path:<dir>, url:<href>#<sha256>, vcpkg:<port>#<triplet>.");
		}

Comment on lines +119 to +125
var request = new FetchRequest(
name,
dependency,
declaringDirectory,
PackagesRoot,
Options,
ExistingLock?.Find(name));
Copilot AI review requested due to automatic review settings July 28, 2026 15:01

vgvgvvv commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Pushed 0a7f0bb. The lock/pin one was the best catch of the whole review — a real correctness bug, not a hardening nit.

A changed pin was silently ignored. The git fetcher reuses the commit the lock recorded rather than asking the remote again — that's what keeps an ordinary build reproducible and offline — but the lock entry was handed over without checking it came from the pin being resolved. So bumping a dependency from tag: v1.0 to tag: v2.0 resolved to the commit v1.0 pointed at, because that commit still exists in the clone. The build stayed on the old version while the manifest said otherwise, and the only visible sign was a lock entry that looked fine. The resolver now only passes an entry whose pin matches.

ChangingTheTagReResolvesInsteadOfReusingTheLock covers it: tags v1.0 and v2.0 in a local repo, restore, bump the manifest, restore again, assert both the lock's resolved sha and the checked-out working tree moved. I verified it fails with the fix reverted.

arm64 triplet — real, and I should have caught it myself. Is64BitOperatingSystem only separates 32- from 64-bit, so every arm64 host got an x64 triplet. This repo targets Apple Silicon and arm64 Linux — Vendor/ninja ships a linux-aarch64 binary and CI runs a macOS arm64 leg — so vcpkg would have installed binaries for the wrong machine and failed a long way from the cause. Now reads RuntimeInformation.OSArchitecture, and throws for an architecture it has no mapping for rather than guessing. The follow-on point about the test was right too: it asserted the old x86/x64-only behaviour and would have started failing on arm64 once fixed. It now checks the triplet against the actual host.

--PackageAdd grammar text — right, it overstated the syntax. Only git needs a # qualifier, having no default revision to fall back on; a url without a sha256 is simply unverified, and a vcpkg port without a triplet uses the host's. Docstring and error message now say that.

ProcessRunner.WaitForExit() — this is the fourth time it's come up, so rather than reply again I've put the reasoning in the code: the parameterless overload is the one that waits for the redirected streams to drain, which is exactly what separates it from WaitForExit(int). The comment ends with "do not 'optimise' this into the timeout overload", which should stop the next reader — human or bot — from reintroducing the bug it's warning about.

Local: 130 passed, 2 skipped (Windows-only), 1 failed — TestHeaderToolCodegen, the sandbox network restriction that passes on CI.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

ReBuildTool/ReBuildTool.Service/PackageService/PackageManifest.cs:140

  • For HTTP archive dependencies, the pin key currently ignores the "strip" value. Changing "strip" in RBTPackage.json will not invalidate the lock entry (or conflict detection), even though it changes the extracted tree layout and can break include/source paths.
			PackageSourceKind.HttpArchive => $"url:{Url}@{Sha256}",

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs:30

  • The archive restore fast-path does not consider the manifest's "strip" value. If a user changes stripComponents, restore can incorrectly treat the existing unpacked tree as satisfied and skip re-extraction (since the stamp only keys on hash/URL).
		var stamp = request.SidecarFile(StampFileName);

ReBuildTool/ReBuildTool.Service/PackageService/PackageRestoreService.cs:74

  • EnsureGitIgnored() claims to be a no-op when the pattern is already present, but it only detects "/Packages/" or "Packages". If a repo already has common variants like "Packages/" or "/Packages", restore will unnecessarily rewrite .gitignore on every first run.
		var pattern = $"/{PackagesFolderName}/";
		var lines = ignorePath.FileExists()
			? ignorePath.ReadAllLines().ToList()
			: new List<string>();
		if (lines.Any(line => line.Trim() == pattern || line.Trim() == PackagesFolderName))
		{

ReBuildTool/ReBuildTool.CppCompiler/Project/CppBuildProject.cs:608

  • This comment says RestoredPackages are "in dependency order", but PackageResolver explicitly returns packages in discovery order (parents are recorded before their dependencies). The summary should match the actual ordering to avoid misleading future changes.
	/// <summary>Packages materialized by the last <see cref="Restore"/>, in dependency order.</summary>

Copilot AI review requested due to automatic review settings July 28, 2026 16:41
Projects can now declare external dependencies in an RBTPackage.json next to
Source/. rbt resolves them transitively, materializes them under Packages/, and
records what each pin actually resolved to in RBTPackage.lock.json.

Restore runs from CppBuildProject.Parse(), before ParseRules(). That ordering is
forced, not incidental: CompileRules.dll is loaded once with Assembly.LoadFile
and cannot be unloaded, so a package's .module.cs has to be on disk before the
rule glob runs - there is no second chance to add rules afterwards. The same
constraint is why the manifest is JSON rather than C#: resolving transitive
dependencies means repeatedly reading the manifest of a package that has not
been downloaded yet.

Packages contribute modules and extensions, never targets - what gets built
stays the consuming project's decision. Their rules are globbed into the same
rule assembly as the project's own, so a package module is depended on by name
like any local one.

Resolution is exact pins only, with no version solver: a git dependency must
name a commit, tag or branch, conflicting pins for one package are a hard error
pointing at the "overrides" escape hatch, and dependency cycles are reported
with the whole chain. The lock stores the commit a tag resolved to, since
upstream can move a tag, and is rewritten only when its content changes -
rbt's incremental checks are timestamp based.

Packages live in <ProjectRoot>/Packages/ rather than under Intermedia/, which
Clean() empties and CleanIfNeed() wipes whenever the rbt binaries are newer than
the last build; putting them there would re-download every dependency after each
rebuild and each rbt update.

Sources in this change are git and path. PackageArgs adds --Offline,
--ForceRestore and --UpdateLock; it lives in CppCompiler because CmdParser
discovers argument groups by scanning loaded assemblies and .NET loads them
lazily. RunMode gains Restore.

CppTargetRule.GitLibraries, the unread predecessor of all this, is marked
[Obsolete]: it hangs off a target rule, which only exists after the rule
assembly is compiled, so it never could have worked.

A project without an RBTPackage.json is entirely unaffected - no Packages/
directory, no lock file, no .gitignore edit.

Tests: manifest validation and lock behaviour, the graph walk (transitive,
diamond, cycle, conflict, override), and an end-to-end restore that clones a
real git repository created in a temp dir. All offline, so the three CI hosts
stay deterministic. Sample/PackageConsumer consumes Sample/GeometryPackage over
a path dependency and is compiled and linked for real by the existing sample
build suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
claude and others added 9 commits July 29, 2026 00:47
Completes the three package shapes and adds the second remote source.

HTTP archives (.zip/.tar.gz/.tgz/.tar) become a package source. This needed the
three capabilities the repo had nowhere: Downloader, ArchiveExtractor and
Hashing in ReBuildTool.Common/Misc. A URL is not self-verifying the way a commit
is - the bytes behind it can change without the manifest changing - so a sha256
in the manifest is what makes an archive dependency reproducible, and a mismatch
aborts the restore printing both hashes rather than unpacking whatever arrived.
Extraction refuses any entry that resolves outside the destination (zip slip),
supports strip-components because release tarballs wrap everything in one
name-version/ directory, and unpacks through a staging directory so an
interrupted run cannot leave a partial tree that a later restore accepts as
complete.

Prebuilt binary packages ship headers and libraries but no rule, so
PackageModuleBinder generates one and PackageArtifactSelector picks the matching
artifact by platform/arch/config - an omitted selector matches every value.
Selection happens at Setup time rather than when the file is generated: baking
the current platform in would change the file's content on every
--TargetPlatform switch, and since the rule assembly is rebuilt off rule-file
timestamps that would recompile every rule each time. Generated this way the
file depends only on the manifest and never churns. Nothing matching is a
warning, not a silent empty link.

Generating a rule file rather than registering an IModuleInterface straight into
ModuleRules keeps everything downstream working unchanged: the ModuleRulePaths
lookup in InitAllRule, the SetupInternal lifecycle, the _API macro codegen, all
four IDE generators and the HeaderTool plugin.

Unmodified upstream source trees ship neither, so the consuming project supplies
a rule through the dependency's "overlay". It is copied into the package root,
not into a generated directory beside it, because an overlay's relative
SourceDirectories/SourceFiles/Exclude* resolve against ModuleDirectory and only
mean anything relative to the upstream tree.

Tests: archive extraction including zip-slip refusal and strip-components, the
download/checksum path served from a loopback HttpListener, artifact selection,
and two integration tests that drive a whole project through restore, rule
compilation and a real toolchain build - one consuming a binary package, one
building upstream sources through an overlay. All offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
Completes the four package sources and adds command-line manifest editing.

The vcpkg fetcher lets vcpkg do the acquiring and building, then translates: it
writes an RBTPackage.json describing the installed tree as a prebuilt binary
package, so PackageModuleBinder and PackageArtifactSelector take it from there
and nothing downstream needs to know vcpkg exists. vcpkg's parallel debug/
prefix is mapped onto rbt's Debug configuration, with an explicit artifact per
release configuration - an artifact with no config would also match Debug and
both library sets would end up on the link line. A port that built no debug
variant has its release libraries serve Debug too, since linking nothing at all
is worse.

The vcpkg checkout is shared under $RBT_HOME/vcpkg rather than per project: a
populated tree is large and slow to rebuild and belongs to no single project.
The tool itself is pinned. It clones over https, not the ssh remote the old
Actions/Vcpkg helper used - a CI runner or a fresh machine has no ssh key.

Triplet comes from the manifest, defaulting to the host's. Restore necessarily
runs before any build context exists, so a cross-compiled build has to state its
triplet explicitly; the triplet is part of the pin, because two triplets of one
port are genuinely different content.

--PackageAdd writes a dependency and restores it in one invocation,
--PackageRemove drops one. Specs are git:<url>#<tag-or-commit>, path:<dir>,
url:<href>#<sha256> and vcpkg:<port>#<triplet>; the qualifier splits off the
last '#' because a URL may contain one, and a 40-char hex qualifier is recorded
as a commit rather than a tag. Edits go through the raw JSON so fields rbt does
not model survive them, and empty fields are not written - a PackageDependency
has one field per source kind and all but one are always empty.

Tests cover the vcpkg installed-tree mapping against a faked tree (running
vcpkg itself needs a large clone, a bootstrap and a network, none of which
belongs in a suite that must pass on three CI hosts; the mapping is where the
decisions are), and the spec grammar and manifest edits in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
Fixes the Windows CI failure and three points from review.

The VS filter walk climbed from a module directory up to the project's Source
folder. Only a module living under Source/ ever reaches that sentinel: a
package's module sits under Packages/, and a package consumed through a path
dependency is not under the project at all - so the walk ran off the top of the
tree and NPath.FileName threw "not valid on a root level directory". It now
stops at the project root and at the filesystem root, and a package outside the
project is grouped under a Modules/ filter by its own directory name rather than
being named relative to the project, which would have produced a "..\..\" filter
Solution Explorer cannot display. This only ever fired on Windows because every
other host defaults to the CMake generator; the new regression test forces VS
generation explicitly, so it now covers all three.

Restore no longer goes through Parse(). Parse() also compiles and loads the rule
assembly and will scaffold a default Target/Module for a project that has none -
neither belongs in "fetch the packages and write the lock, then stop", and the
scaffolding in particular meant --Mode Restore could write source files into a
bare checkout. IProjectInterface gains Restore() and Program.cs dispatches to it
directly.

vcpkg bootstrap runs through cmd.exe /c on Windows. A .bat is not an executable
image, so CreateProcess cannot launch it and ProcessRunner does not use
ShellExecute - as written it would have failed on every Windows host.

An omitted vcpkg triplet now resolves to the host's before pins are compared.
Previously { "vcpkg": "fmt" } and { "vcpkg": "fmt", "triplet": "<host>" } produced
different pin keys and would be reported as conflicting on the very machine where
they are identical, and the lock recorded a pin that did not say which triplet
was built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
Three comments no longer matched the code they describe:

- CppBuildProject's RestoredPackages cref still pointed at RestorePackages,
  renamed to Restore() in the previous commit, so the cref no longer resolved.
- PackageLock.Resolved and FetchedPackage.Resolved both said a path dependency
  records an absolute path. It records the path exactly as declared - deliberately,
  since the lock is committed and an absolute path would be meaningless in any
  other checkout. Both now say what the field actually holds for each source.

LocalServer in the archive tests probed for a free port by assigning the readonly
field inside the loop and then null-checking a non-nullable field. It compiled
(definite assignment is not enforced for class fields) but read as though it
might not have. The probe now uses a local and the fields are assigned once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
… URL

Three findings from review, two of them about trusting a manifest too far. A
package fetched from a remote declares its own transitive dependencies, so the
names arriving from it are exactly as trustworthy as the package is - and rbt
turns them into filesystem paths and into generated C# it then compiles and runs.

Package names are now validated before they are combined into a path. The check
sits in the resolver, so it covers every name in the walk rather than only the
ones in the project's own manifest, and is re-applied in FetchRequest for any
future caller building a request directly. A dependency keyed "../outside" would
previously have placed the package next to Packages/ instead of inside it; the
sidecar file the archive fetcher writes had the same hole and now goes through
the same containment check.

Binary-package module names are validated as plain C# identifiers. The name is
interpolated into "public class <name>" in a generated rule that becomes part of
CompileRules.dll, so a name carrying punctuation could have closed the
declaration and appended arbitrary code to an assembly rbt executes during the
build. Rejected rather than escaped: a package still has no business naming a
class outside its own.

The archive fetcher's cache hit is keyed on the origin URL when the manifest
gives no sha256. Previously any stamp satisfied that case, so editing a
dependency's url left the previous archive unpacked on disk while the lock
recorded the new origin - stale content under a fresh-looking pin. The stamp now
carries the URL alongside the hash; a stamp from an older rbt simply misses once
and re-downloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
…pkg version

Two binary packages could both name their module the same thing. Both generate
to Packages/.generated/<module>/<module>.module.cs - one file - so the second
write won and the build quietly depended on which package was processed last.
The binder now tracks the names it has generated and reports both culprits. The
same clash between two source packages was already caught in ParseRules, where
they are two distinct files claiming one name; this path had to catch its own.

PackageDependency.Version is removed. Nothing ever read it: which version of a
vcpkg port you get is decided by the vcpkg checkout, which the fetcher pins to a
fixed tag, and real per-port pinning would need vcpkg manifest mode and a
versioning baseline that this bridge does not set up. Keeping the field would
have left a knob that silently does nothing, and it also made every vcpkg pin
read "vcpkg:fmt@:x64-windows" in lock files and conflict messages. Pins are now
"vcpkg:<port>:<triplet>".

The restore result's ordering comment claimed deepest-first. It is not: a package
is recorded before its own dependencies are walked, so it precedes them. Nothing
downstream depends on the order - the packages become rule-glob roots and the
lock is sorted by name on write - so the comment now says what actually holds
rather than promising a guarantee that was never provided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
Four more from review, all variants of letting a manifest reach further than it
should.

A git URL or revision out of a manifest becomes an argv element. ArgumentList
already rules out shell injection, but git itself still reads a leading '-' as an
option, so a value like "--upload-pack=..." would have turned into a git flag.
Both are now validated where the rest of the manifest is validated, once, rather
than at each site that shells out. Rejecting the shape is portable;
--end-of-options would have added a git 2.24 floor for the same effect. git clone
also gets an explicit "--" so it does not depend on that check staying in place.

A binary package's relative include and library paths could climb out of the
package with "../..", putting arbitrary directories of the consuming machine on
the include or library search path. Relative entries must now resolve inside the
package. Absolute entries stay allowed on purpose - that is what the vcpkg bridge
emits, since a vcpkg installed tree lives outside Packages/ by design.

ProcessRunner silently ignored a working directory that did not exist, which
meant the tool ran in rbt's own working directory instead: a git command against
the wrong repository, failing in a way that gives no hint why. A directory the
caller named now has to be there. ReBuildTool.Service gains InternalsVisibleTo
for the test project, matching what ReBuildTool.CppCompiler already does, so the
runner can be covered directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
…plet

A changed pin was silently ignored. The git fetcher reuses the commit the lock
recorded rather than asking the remote again - that is what keeps an ordinary
build reproducible and offline - but the lock entry was handed over without
checking it came from the pin being resolved. Bumping a dependency's tag from
v1.0 to v2.0 therefore resolved to the commit v1.0 pointed at, which still exists
in the clone, and the build stayed on the old version while the manifest said
otherwise. The resolver now only passes an entry whose pin matches.

DefaultTriplet inferred the architecture from Is64BitOperatingSystem, which only
separates 32- from 64-bit, so every arm64 host was handed an x64 triplet - and
rbt targets Apple Silicon and arm64 Linux (Vendor/ninja ships a linux-aarch64
binary and CI runs a macOS arm64 leg). It now reads RuntimeInformation
.OSArchitecture and throws for an architecture it has no mapping for, rather than
installing binaries for the wrong machine and failing somewhere far from the
cause. Its test asserted the old x86/x64-only behaviour and now checks the
triplet against the actual host.

--PackageAdd's grammar text claimed the '#' qualifier is required everywhere and
that git pins are tag-only. Only git needs one, having no default revision to
fall back on; a url without a sha256 is simply unverified and a vcpkg port
without a triplet uses the host's. Docstring and error message now say so.

ProcessRunner's WaitForExit gets a comment recording why the parameterless
overload is the correct one - it is the version that waits for the redirected
streams to drain, which is what keeps captured output complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nEaEVhDjn2dQC19BE9iaM
@vgvgvvv
vgvgvvv force-pushed the claude/package-management-implementation-oszbcs branch from 89f82c0 to b66c3f8 Compare July 28, 2026 16:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

ReBuildTool/ReBuildTool.Common/Misc/ArchiveExtractor.cs:127

  • ResolveEntryPath blocks .. segments and also checks IsChildOf, but on Windows an entry name containing : (e.g. foo.txt:evil) can target an NTFS alternate data stream. That allows archives to smuggle content in a way that is not visible in Explorer and may bypass expectations about extracted file names. Consider rejecting any segment containing : on Windows (or universally) before combining into the destination path.
		if (segments.Any(segment => segment == ".."))
		{
			throw new IOException(
				$"refusing to extract \"{entryName}\": the archive entry escapes the destination directory.");
		}

ReBuildTool/ReBuildTool.IDE/VisualStudio/VCProject.Filter.cs:152

  • Visual Studio filter generation currently adds all files under moduleDirectory. With git-sourced packages, the module directory is typically the clone root and GitPackageFetcher keeps the .git directory; that would pull the entire git object database into the generated project/filters, making the .vcxproj/filters huge and slow (and potentially overwhelming VS). It looks worth excluding .git (and the .git file case used by submodules/worktrees) from this enumeration.
		moduleDirectory.Files(true).ToList().ForEach(file =>
		{
			GetOrAddFilter(file.Parent, moduleDirectory).Files.Add(file.RelativeTo(outputFolder));
		});

Copilot AI review requested due to automatic review settings July 28, 2026 16:48
@vgvgvvv
vgvgvvv merged commit e894bd1 into main Jul 28, 2026
17 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

ReBuildTool/ReBuildTool.Service/PackageService/ProcessRunner.cs:90

  • ProcessRunner uses async OutputDataReceived/ErrorDataReceived handlers but disposes the Process immediately after a single WaitForExit(). WaitForExit() only guarantees the process exited; it can still return before the async handlers have drained, which can truncate the tail of stdout/stderr.
		// The parameterless WaitForExit is the overload that also waits for the redirected streams
		// to reach EOF and the async handlers above to drain - that is precisely what separates it
		// from WaitForExit(int), which returns as soon as the process is gone and can leave the
		// last lines uncollected. Do not "optimise" this into the timeout overload.
		process.WaitForExit();

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs:99

  • The stamp file only records (hash, url). If extraction behavior (e.g. strip components) changes, there’s no way to detect it later. Persist strip in the stamp so the cache hit is only taken when both content identity and extraction options match.
		stamp.WriteAllText($"{actual}{Environment.NewLine}{url}{Environment.NewLine}");

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs:114

  • ReadStamp assumes exactly two lines. Once the stamp includes strip (and any future metadata), this should parse the extra fields with a backward-compatible default so older stamps still work.
	private static (string Hash, string Url) ReadStamp(NPath stamp)
	{
		var lines = stamp.ReadAllLines();
		return (
			lines.Length > 0 ? lines[0].Trim() : string.Empty,
			lines.Length > 1 ? lines[1].Trim() : string.Empty);
	}

ReBuildTool/ReBuildTool.Service/PackageService/Fetchers/HttpArchivePackageFetcher.cs:48

  • The “already unpacked” fast path ignores the manifest’s strip setting. If a user changes "strip" in RBTPackage.json, restore will still treat the existing directory as satisfied (by sha/url only) and will not re-extract with the new layout.

This issue also appears in the following locations of the same file:

  • line 99
  • line 108
			var satisfied = expected != null
				? Hashing.Matches(expected, stampedHash)
				: stampedUrl == url;
			if (satisfied)

ReBuildTool/ReBuildTool.Service/PackageService/PackageManifest.cs:145

  • PinKey() ignores fields that materially change the restored result (e.g. archive "strip" and dependency "overlay"). That makes (a) conflicting declarations with different overlays/strip look interchangeable, and (b) lock entries potentially reused even when the manifest semantics changed.
		return ResolveKind(packageName) switch
		{
			PackageSourceKind.Git => $"git:{Git}@{GitRevision}",
			PackageSourceKind.HttpArchive => $"url:{Url}@{Sha256}",
			PackageSourceKind.Path => $"path:{Path}",

Sample/PackageConsumer/global.json:7

  • This global.json allows prerelease SDKs and rolls forward to the latest major. That can make builds non-reproducible over time (e.g. a future .NET 9 preview getting selected) even though the file pins 8.0.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants