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
1 change: 1 addition & 0 deletions AGENTS-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。
2. 桌面端开发优先使用 `pnpm run desktop:dev` — 提供完整热更新(Vite HMR + Rust 自动重编译并重启)。仅在需要更快冷启动且只迭代前端时使用 `pnpm run desktop:preview:debug`(Rust 改动不会自动重编译)。
3. 修改 Rust 文件后,优先使用 `pnpm run fmt:rs`,只格式化已改动或已暂存的 `.rs` 文件。只有在你明确需要更大范围格式化时才使用 `cargo fmt`。
4. 改完后按下方表格执行与改动范围匹配的最小验证。
5. Rust workspace 依赖应在根清单中统一版本,而由消费 crate 按自身职责声明所需 feature;仅测试所需的 feature 应放入 `dev-dependencies`,受 crate feature 控制的服务能力应只在对应 feature 中启用。禁止使用 `tokio/full` 绕过依赖边界设计。

## 分层模块索引

Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Repository rule: **keep product logic platform-agnostic, then expose it through
2. For desktop development, prefer `pnpm run desktop:dev` — it provides full hot-reload (Vite HMR + Rust auto-rebuild & restart). Use `pnpm run desktop:preview:debug` only when you need a faster cold-start for frontend-only iteration (Rust changes are not auto-rebuilt).
3. After Rust file changes, prefer `pnpm run fmt:rs` to format only changed or staged `.rs` files. Use `cargo fmt` only when you intentionally want broader formatting coverage.
4. After changes, run the smallest matching verification from the table below.
5. Workspace Rust dependencies own compatible versions, not broad capability
unions. Each crate must select the dependency features it actually uses;
keep test-only features in dev-dependencies and attach feature-gated service
capabilities to the owning crate feature. `tokio/full` is forbidden in the
root workspace and workspace members.

## Layered Module Index

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ undocumented_unsafe_blocks = "warn"
# Shared dependency versions to keep all crates aligned
[workspace.dependencies]
# Async runtime
tokio = { version = "1.52", features = ["full"] }
tokio = { version = "1.52", default-features = false }
tokio-stream = "0.1.18"
tokio-util = "0.7.18"
async-trait = "0.1.89"
Expand Down
77 changes: 77 additions & 0 deletions scripts/check-core-boundaries.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { access, readFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import assert from 'node:assert/strict';

Expand All @@ -10,6 +11,8 @@ import {
findCargoLayerViolations,
findFeatureGatedTestTargetViolations,
findProductEntrypointCoreFeatureViolations,
findServicesIntegrationsTokioFeatureViolations,
findTokioDependencyFeatureViolations,
} from './core-boundaries/cargo-dependency-boundaries.mjs';
import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs';

Expand All @@ -30,6 +33,31 @@ const MODULES = [

const TEST_ROOT = join('C:', 'repo');

function parseManifestFeatures(manifest) {
const section = manifest.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? '';
const features = {};

for (const match of section.matchAll(/^([a-zA-Z0-9_-]+)\s*=\s*\[([\s\S]*?)\]/gm)) {
features[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map((value) => value[1]);
}

return features;
}

function removeFeatureValue(manifest, feature, value) {
const featurePattern = new RegExp(`^${feature}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'm');
return manifest.replace(featurePattern, (definition) =>
definition.replace(new RegExp(`\\s*["']${value}["'],?`), ''));
}

function servicesIntegrationsPackage(manifest) {
return {
name: 'bitfun-services-integrations',
manifest_path: join(TEST_ROOT, 'src', 'crates', 'services', 'services-integrations', 'Cargo.toml'),
features: parseManifestFeatures(manifest),
};
}

function packageAt(name, repoManifestPath, dependencies = []) {
return {
id: name,
Expand Down Expand Up @@ -289,6 +317,55 @@ test('cargo layer checker rejects reverse edges across dependency kinds', () =>
assert.match(violations[5].message, /contract.*contracts.*->.*service.*services.*build dependency/);
});

test('workspace Tokio capabilities stay crate-owned', async () => {
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url));
const workspaceManifest = await readFile(new URL('../Cargo.toml', import.meta.url), 'utf8');
const workspaceTokio = workspaceManifest.match(/^tokio\s*=\s*\{[^}]+\}/m)?.[0];

assert.ok(workspaceTokio, 'workspace dependencies must declare Tokio once');
assert.match(workspaceTokio, /default-features\s*=\s*false/);
assert.doesNotMatch(workspaceTokio, /(?:^|,\s*)features\s*=/);
const packages = collectCargoMetadataPackages({ root: repositoryRoot });
assert.deepEqual(findTokioDependencyFeatureViolations(packages), []);
});

test('services integrations Tokio owner contracts reject feature-union masking', async () => {
const manifest = await readFile(
new URL('../src/crates/services/services-integrations/Cargo.toml', import.meta.url),
'utf8',
);
const mutations = [
['plugin-source', 'tokio/time', /plugin-source missing effective Tokio capabilities: time/],
['mcp', 'tokio/process', /mcp missing effective Tokio capabilities: process/],
['miniapp-market', 'miniapp-runtime', /miniapp-market missing effective Tokio capabilities: fs/],
['function-agents', 'git', /function-agents missing effective Tokio capabilities: fs/],
['remote-ssh-concrete', 'remote-ssh', /remote-ssh-concrete missing effective Tokio capabilities: fs/],
];

for (const [feature, value, expected] of mutations) {
const mutated = removeFeatureValue(manifest, feature, value);
assert.notEqual(mutated, manifest, `${feature} must own ${value} in the fixture`);
const messages = findServicesIntegrationsTokioFeatureViolations(
servicesIntegrationsPackage(mutated),
).map((violation) => violation.message).join('\n');
assert.match(messages, expected);
}
});

test('Cargo metadata Tokio policy catches table-style and renamed full dependencies', () => {
const pkg = packageAt('table-style', 'src/crates/services/table-style/Cargo.toml', [{
name: 'tokio',
rename: 'async_runtime',
kind: null,
optional: false,
features: ['full'],
}]);
const violations = findTokioDependencyFeatureViolations([pkg]);

assert.equal(violations.length, 1);
assert.match(violations[0].message, /table-style must not enable tokio\/full/);
});

test('cargo layer checker allows documented downward and peer dependencies', () => {
const packages = [
packageAt('entry', 'src/apps/example/Cargo.toml', [
Expand Down
138 changes: 138 additions & 0 deletions scripts/core-boundaries/cargo-dependency-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,143 @@ function dependencyDescription(dependency) {
return `${kind}${optional} dependency${target}`;
}

const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([
['announcement', ['fs', 'sync']],
['browser-control', ['time']],
['canvas-runtime', ['fs']],
['debug-log', ['rt']],
['deep-research', ['fs']],
['git', ['fs', 'io-util', 'macros', 'rt', 'time']],
['file-watch', ['rt', 'sync']],
['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']],
['mcp', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']],
['miniapp-runtime', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']],
['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']],
['plugin-source', ['fs', 'rt', 'sync', 'time']],
['hook-import', ['fs', 'sync']],
['remote-connect', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']],
['remote-ssh', ['fs', 'io-util', 'macros', 'net', 'process', 'rt', 'sync', 'time']],
['remote-ssh-concrete', ['fs', 'io-util', 'macros', 'net', 'process', 'rt', 'sync', 'time']],
['review-platform', ['fs', 'io-util', 'sync']],
['speech', ['fs', 'io-util', 'macros', 'rt', 'sync']],
['workspace-search', ['io-util', 'rt', 'sync', 'time']],
['script-tool-runtime', ['io-util', 'process', 'rt', 'sync', 'time']],
]);

// The installer is an excluded standalone workspace with its own Rust checks
// and packaging lifecycle; this policy governs the root product workspace.
const TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES = new Set(['bitfun-installer']);

function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) {
if (visiting.has(feature)) {
return new Set();
}
visiting.add(feature);

const capabilities = new Set();
for (const value of featureGraph[feature] ?? []) {
if (value.startsWith('tokio/')) {
capabilities.add(value.slice('tokio/'.length));
} else if (Object.hasOwn(featureGraph, value)) {
for (const capability of effectiveTokioCapabilities(value, featureGraph, visiting)) {
capabilities.add(capability);
}
}
}

visiting.delete(feature);
return capabilities;
}

export function findServicesIntegrationsTokioFeatureViolations(pkg) {
const violations = [];
const featureGraph = pkg.features ?? {};

for (const [feature, expectedCapabilities] of SERVICES_INTEGRATIONS_TOKIO_FEATURES) {
if (!Object.hasOwn(featureGraph, feature)) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name}:${feature} governed Tokio feature is missing`,
});
continue;
}

const actualCapabilities = [...effectiveTokioCapabilities(feature, featureGraph)].sort();
const expected = [...expectedCapabilities].sort();
const missing = expected.filter((capability) => !actualCapabilities.includes(capability));
const unexpected = actualCapabilities.filter((capability) => !expected.includes(capability));
if (missing.length > 0) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name}:${feature} missing effective Tokio capabilities: ${missing.join(', ')}`,
});
}
if (unexpected.length > 0) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name}:${feature} has unexpected effective Tokio capabilities: ${unexpected.join(', ')}`,
});
}
}

for (const [feature, values] of Object.entries(featureGraph)) {
if (SERVICES_INTEGRATIONS_TOKIO_FEATURES.has(feature)) {
continue;
}
if (values.some((value) => value.startsWith('tokio/'))) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name}:${feature} Tokio capabilities require an explicit owner contract`,
});
}
}

return violations;
}

export function findTokioDependencyFeatureViolations(packages) {
const violations = [];

for (const pkg of packages) {
if (TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES.has(pkg.name)) {
continue;
}
for (const dependency of pkg.dependencies ?? []) {
if (dependency.name !== 'tokio') {
continue;
}
const features = dependency.features ?? [];
if (features.includes('full')) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name} must not enable tokio/full for its ${dependencyDescription(dependency)}`,
});
}
const featureOwnedIntegrationRuntime =
pkg.name === 'bitfun-services-integrations'
&& (dependency.kind ?? null) === null;
if (features.length === 0 && !featureOwnedIntegrationRuntime) {
violations.push({
path: pkg.manifest_path,
line: 1,
message: `${pkg.name} must declare explicit Tokio capabilities for its ${dependencyDescription(dependency)}`,
});
}
}

if (pkg.name === 'bitfun-services-integrations') {
violations.push(...findServicesIntegrationsTokioFeatureViolations(pkg));
}
}

return violations;
}

export function findCargoLayerViolations(
packages,
{ root, crateLayoutRules },
Expand Down Expand Up @@ -590,6 +727,7 @@ export function checkCargoDependencyBoundaries({ root, crateLayoutRules }) {
{ root, crateLayoutRules },
),
...findFeatureGatedTestTargetViolations(packages),
...findTokioDependencyFeatureViolations(packages),
];
}

Expand Down
23 changes: 22 additions & 1 deletion scripts/core-boundaries/rules/source/required-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3879,6 +3879,10 @@ export const requiredContentRules = [
regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod agentic\b/s,
message: 'agentic runtime must stay behind product-full for no-default builds',
},
{
regex: /#\[cfg\(feature = "product-full"\)\]\s*mod external_subagents\b/s,
message: 'external subagent product assembly must stay behind product-full',
},
{
regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s,
message: 'function-agent product domain facade must stay behind product-domains',
Expand All @@ -3893,6 +3897,23 @@ export const requiredContentRules = [
},
],
},
{
path: 'src/crates/assembly/core/src/service/dispatch/mod.rs',
reason:
'no-default dispatch cleanup must retain claimed records when the product worktree owner is unavailable',
patterns: [
{
regex:
/#\[cfg\(feature = "product-full"\)\]\s*async fn release_baseline_claim\b/s,
message: 'worktree-backed dispatch claim release must stay behind product-full',
},
{
regex:
/#\[cfg\(not\(feature = "product-full"\)\)\]\s*async fn release_baseline_claim\([^)]*\)\s*->\s*Result<\(\), DispatchStoreError>\s*\{\s*Err\(\s*DispatchStoreError::ClaimRelease\([\s\S]*?\)\s*\)\s*\}/s,
message: 'no-default dispatch claim release must fail closed',
},
],
},
{
path: 'src/crates/assembly/core/src/infrastructure/mod.rs',
reason: 'concrete AI adapter runtime and debug ingest HTTP server must stay out of no-default core builds',
Expand Down Expand Up @@ -3982,7 +4003,7 @@ export const requiredContentRules = [
message: 'worktree topology owner import must stay gated for no-default builds',
},
{
regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = workspace_root;\s*return None;\s*\}/s,
regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = \(workspace_root, freshness\);\s*return None;\s*\}/s,
message: 'no-default worktree enrichment fallback must remain explicit',
},
],
Expand Down
34 changes: 34 additions & 0 deletions scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3785,6 +3785,7 @@ export function runManifestParserSelfTest({
contracts: [
'feature = "product-full"',
'pub mod agentic',
'mod external_subagents',
'feature = "product-domains"',
'pub mod function_agents',
'pub mod miniapp',
Expand Down Expand Up @@ -3847,6 +3848,15 @@ export function runManifestParserSelfTest({
'session_store_migration_error',
],
},
{
path: 'src/crates/assembly/core/src/service/dispatch/mod.rs',
contracts: [
'feature = "product-full"',
'not\\(feature = "product-full"\\)',
'release_baseline_claim',
'DispatchStoreError::ClaimRelease',
],
},
{
path: 'src/crates/interfaces/acp/src/client/manager.rs',
contracts: ['CLIENT_STARTUP_TIMEOUT_SECS', 'startup_timeout_error_message', 'formats_startup_timeout_error_message'],
Expand Down Expand Up @@ -4419,6 +4429,30 @@ export function runManifestParserSelfTest({
}
}

const dispatchClaimReleaseRule = requiredContentRules
.find((rule) => rule.path === 'src/crates/assembly/core/src/service/dispatch/mod.rs')
?.patterns.find((pattern) => pattern.message === 'no-default dispatch claim release must fail closed');
if (!dispatchClaimReleaseRule) {
throw new Error('missing no-default dispatch claim release boundary rule');
}
const failClosedDispatchRelease = `
#[cfg(not(feature = "product-full"))]
async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), DispatchStoreError> {
Err(DispatchStoreError::ClaimRelease(format!("job_id={}", release.job_id)))
}`;
const unsafeDispatchRelease = `
#[cfg(not(feature = "product-full"))]
async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), DispatchStoreError> {
let _ignored = DispatchStoreError::ClaimRelease(format!("job_id={}", release.job_id));
Ok(())
}`;
if (!dispatchClaimReleaseRule.regex.test(failClosedDispatchRelease)) {
throw new Error('no-default dispatch claim release rule must accept a direct fail-closed return');
}
if (dispatchClaimReleaseRule.regex.test(unsafeDispatchRelease)) {
throw new Error('no-default dispatch claim release rule must reject a discarded error followed by success');
}

const sessionControlRuleText = forbiddenRuleTextForPath(
'src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs',
);
Expand Down
Loading
Loading