@smoothbricks/cli 0.11.11 → 0.11.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/monorepo/cargo-policy.d.ts +25 -1
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +396 -4
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +5 -1
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +13 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +6 -0
- package/package.json +2 -2
- package/src/monorepo/cargo-policy.test.ts +206 -3
- package/src/monorepo/cargo-policy.ts +341 -2
- package/src/monorepo/index.ts +5 -1
- package/src/monorepo/packs/index.test.ts +32 -0
- package/src/monorepo/packs/index.ts +13 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import { validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
|
+
import { applyCargoFeatureUnification, type CargoHakariShell, validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
7
|
|
|
7
8
|
async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
|
|
8
9
|
const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
|
|
@@ -18,17 +19,46 @@ async function withFixture<T>(files: Record<string, string>, callback: (root: st
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
async function check(
|
|
22
|
+
async function check(
|
|
23
|
+
files: Record<string, string>,
|
|
24
|
+
shell?: CargoHakariShell,
|
|
25
|
+
): Promise<{ failures: number; messages: string[] }> {
|
|
22
26
|
return withFixture(files, (root) => {
|
|
23
27
|
const captured = captureErrors();
|
|
24
28
|
try {
|
|
25
|
-
return { failures: validateCargoCachePolicy(root), messages: captured.messages };
|
|
29
|
+
return { failures: validateCargoCachePolicy(root, { shell }), messages: captured.messages };
|
|
26
30
|
} finally {
|
|
27
31
|
captured.restore();
|
|
28
32
|
}
|
|
29
33
|
});
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/** Records what update would run, and answers `verify` however the test needs. */
|
|
37
|
+
function recordingHakari(
|
|
38
|
+
verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
|
|
39
|
+
): CargoHakariShell & {
|
|
40
|
+
calls: string[][];
|
|
41
|
+
} {
|
|
42
|
+
const calls: string[][] = [];
|
|
43
|
+
return {
|
|
44
|
+
calls,
|
|
45
|
+
run(_directory, args) {
|
|
46
|
+
calls.push([...args]);
|
|
47
|
+
return args[0] === 'verify'
|
|
48
|
+
? { code: verify.code, output: verify.output ?? '', missing: verify.missing ?? false }
|
|
49
|
+
: { code: 0, output: '', missing: false };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NIGHTLY_DEVENV = 'languages.rust = {\n channel = "nightly";\n};\n';
|
|
55
|
+
const UNIFIED_CONFIG = '[unstable]\nfeature-unification = true\n\n[resolver]\nfeature-unification = "workspace"\n';
|
|
56
|
+
const TWO_CRATE_WORKSPACE = {
|
|
57
|
+
'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
58
|
+
'crates/alpha/Cargo.toml': '[package]\nname = "alpha"\n',
|
|
59
|
+
'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
|
|
60
|
+
};
|
|
61
|
+
|
|
32
62
|
function captureErrors(): { messages: string[]; restore: () => void } {
|
|
33
63
|
const messages: string[] = [];
|
|
34
64
|
const error = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
@@ -186,3 +216,176 @@ describe('Cargo cache policy', () => {
|
|
|
186
216
|
expect(result.messages).toEqual([]);
|
|
187
217
|
});
|
|
188
218
|
});
|
|
219
|
+
|
|
220
|
+
describe('Cargo workspace feature unification', () => {
|
|
221
|
+
it('leaves a single-crate workspace alone', async () => {
|
|
222
|
+
// Nothing to unify: `feature-unification = "workspace"` and a workspace-hack
|
|
223
|
+
// both exist to stop ONE dependency being built twice with different
|
|
224
|
+
// features for two members, which needs two members.
|
|
225
|
+
const result = await check({
|
|
226
|
+
'Cargo.toml': '[workspace]\nmembers = ["crates/only"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
227
|
+
'crates/only/Cargo.toml': '[package]\nname = "only"\n',
|
|
228
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
229
|
+
});
|
|
230
|
+
expect(result.failures).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('requires a mechanism once a workspace holds more than one crate', async () => {
|
|
234
|
+
const result = await check({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV });
|
|
235
|
+
expect(result.failures).toBe(1);
|
|
236
|
+
expect(result.messages[0]).toContain('feature-unification');
|
|
237
|
+
expect(result.messages[0]).toContain('smoo monorepo update');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('accepts the nightly resolver configuration and rejects it without the unstable flag', async () => {
|
|
241
|
+
const configured = await check({
|
|
242
|
+
...TWO_CRATE_WORKSPACE,
|
|
243
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
244
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
245
|
+
});
|
|
246
|
+
expect(configured.failures).toBe(0);
|
|
247
|
+
|
|
248
|
+
// Cargo silently ignores [resolver] feature-unification without the
|
|
249
|
+
// unstable opt-in, so the half-configured repository believes it is unified
|
|
250
|
+
// while every crate still re-resolves features.
|
|
251
|
+
const inert = await check({
|
|
252
|
+
...TWO_CRATE_WORKSPACE,
|
|
253
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
254
|
+
'.cargo/config.toml': '[resolver]\nfeature-unification = "workspace"\n',
|
|
255
|
+
});
|
|
256
|
+
expect(inert.failures).toBe(1);
|
|
257
|
+
expect(inert.messages[0]).toContain('[unstable] feature-unification = true');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('reads the channel from the managed devenv module, not from a decorative rust-toolchain file', async () => {
|
|
261
|
+
// devenv resolves the toolchain through rust-overlay and ignores
|
|
262
|
+
// rust-toolchain.toml unless languages.rust.toolchainFile names it, so the
|
|
263
|
+
// module's channel is the compiler that actually runs these commands.
|
|
264
|
+
const result = await check({
|
|
265
|
+
...TWO_CRATE_WORKSPACE,
|
|
266
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
267
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
268
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
269
|
+
});
|
|
270
|
+
expect(result.failures).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('refuses the nightly-only resolver key on a stable toolchain', async () => {
|
|
274
|
+
const result = await check({
|
|
275
|
+
...TWO_CRATE_WORKSPACE,
|
|
276
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
277
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
278
|
+
});
|
|
279
|
+
expect(result.failures).toBe(1);
|
|
280
|
+
expect(result.messages[0]).toContain('cargo-hakari');
|
|
281
|
+
expect(result.messages[0]).toContain('stable');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('accepts a hakari-managed workspace-hack on a stable toolchain', async () => {
|
|
285
|
+
const hakari = recordingHakari();
|
|
286
|
+
const result = await check(
|
|
287
|
+
{
|
|
288
|
+
'Cargo.toml':
|
|
289
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
290
|
+
'crates/alpha/Cargo.toml':
|
|
291
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
292
|
+
'crates/beta/Cargo.toml':
|
|
293
|
+
'[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
294
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
295
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\nresolver = "2"\n',
|
|
296
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
297
|
+
},
|
|
298
|
+
hakari,
|
|
299
|
+
);
|
|
300
|
+
expect(result.failures).toBe(0);
|
|
301
|
+
expect(hakari.calls).toEqual([['verify']]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('flags a crate that does not depend on the workspace-hack', async () => {
|
|
305
|
+
const result = await check(
|
|
306
|
+
{
|
|
307
|
+
'Cargo.toml':
|
|
308
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
309
|
+
'crates/alpha/Cargo.toml':
|
|
310
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
311
|
+
'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
|
|
312
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
313
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
314
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
315
|
+
},
|
|
316
|
+
recordingHakari(),
|
|
317
|
+
);
|
|
318
|
+
expect(result.failures).toBe(1);
|
|
319
|
+
expect(result.messages[0]).toContain('beta');
|
|
320
|
+
expect(result.messages[0]).toContain('smoo monorepo update');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('surfaces a stale workspace-hack that cargo hakari verify rejects', async () => {
|
|
324
|
+
const result = await check(
|
|
325
|
+
{
|
|
326
|
+
'Cargo.toml':
|
|
327
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
328
|
+
'crates/alpha/Cargo.toml':
|
|
329
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
330
|
+
'crates/beta/Cargo.toml':
|
|
331
|
+
'[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
332
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
333
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
334
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
335
|
+
},
|
|
336
|
+
recordingHakari({ code: 1, output: 'workspace-hack is not up-to-date' }),
|
|
337
|
+
);
|
|
338
|
+
expect(result.failures).toBe(1);
|
|
339
|
+
expect(result.messages[0]).toContain('cargo hakari verify');
|
|
340
|
+
expect(result.messages[0]).toContain('workspace-hack is not up-to-date');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('Cargo feature unification update', () => {
|
|
345
|
+
it('writes the nightly resolver configuration once', async () => {
|
|
346
|
+
await withFixture({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV }, (root) => {
|
|
347
|
+
const configPath = join(root, '.cargo/config.toml');
|
|
348
|
+
const hakari = recordingHakari();
|
|
349
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
350
|
+
const written = readFileSync(configPath, 'utf8');
|
|
351
|
+
expect(written).toContain('[unstable]\nfeature-unification = true');
|
|
352
|
+
expect(written).toContain('[resolver]\nfeature-unification = "workspace"');
|
|
353
|
+
expect(hakari.calls).toEqual([]);
|
|
354
|
+
|
|
355
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
356
|
+
expect(readFileSync(configPath, 'utf8')).toBe(written);
|
|
357
|
+
const captured = captureErrors();
|
|
358
|
+
try {
|
|
359
|
+
expect(validateCargoCachePolicy(root, { shell: hakari })).toBe(0);
|
|
360
|
+
} finally {
|
|
361
|
+
captured.restore();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('generates and wires a workspace-hack on a stable toolchain', async () => {
|
|
367
|
+
await withFixture(
|
|
368
|
+
{ ...TWO_CRATE_WORKSPACE, 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n' },
|
|
369
|
+
(root) => {
|
|
370
|
+
const hakari = recordingHakari();
|
|
371
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
372
|
+
expect(hakari.calls).toEqual([['init', 'workspace-hack'], ['generate'], ['manage-deps', '--yes']]);
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('skips hakari init when the workspace already declares one', async () => {
|
|
378
|
+
await withFixture(
|
|
379
|
+
{
|
|
380
|
+
...TWO_CRATE_WORKSPACE,
|
|
381
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n',
|
|
382
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
383
|
+
},
|
|
384
|
+
(root) => {
|
|
385
|
+
const hakari = recordingHakari();
|
|
386
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
387
|
+
expect(hakari.calls).toEqual([['generate'], ['manage-deps', '--yes']]);
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
4
|
import typia from 'typia';
|
|
4
5
|
import { parsePackageJsonText } from '../lib/json.js';
|
|
@@ -21,6 +22,11 @@ interface CargoManifest {
|
|
|
21
22
|
workspace?: string | boolean;
|
|
22
23
|
};
|
|
23
24
|
profile?: Record<string, CargoProfile>;
|
|
25
|
+
// Only the KEYS matter here: a workspace-hack is wired in by name, and every
|
|
26
|
+
// dependency spelling — string, table, inherited — carries the same key.
|
|
27
|
+
dependencies?: Record<string, unknown>;
|
|
28
|
+
'dev-dependencies'?: Record<string, unknown>;
|
|
29
|
+
'build-dependencies'?: Record<string, unknown>;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
interface CargoConfigTarget {
|
|
@@ -42,6 +48,22 @@ interface CargoConfig {
|
|
|
42
48
|
};
|
|
43
49
|
target?: Record<string, CargoConfigTarget>;
|
|
44
50
|
env?: Record<string, string | CargoConfigEnvObject>;
|
|
51
|
+
resolver?: {
|
|
52
|
+
'feature-unification'?: string;
|
|
53
|
+
};
|
|
54
|
+
unstable?: {
|
|
55
|
+
'feature-unification'?: boolean;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RustToolchainFile {
|
|
60
|
+
toolchain?: {
|
|
61
|
+
channel?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface HakariConfig {
|
|
66
|
+
'hakari-package'?: string;
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
interface LoadedManifest {
|
|
@@ -62,6 +84,45 @@ interface EffectiveProfile {
|
|
|
62
84
|
|
|
63
85
|
const validateCargoManifest = typia.createValidate<CargoManifest>();
|
|
64
86
|
const validateCargoConfig = typia.createValidate<CargoConfig>();
|
|
87
|
+
const validateRustToolchainFile = typia.createValidate<RustToolchainFile>();
|
|
88
|
+
const validateHakariConfig = typia.createValidate<HakariConfig>();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one command this policy shells out to, behind a seam. `cargo hakari
|
|
92
|
+
* verify` is the only authority on whether a generated workspace-hack still
|
|
93
|
+
* unifies what the workspace resolves today; nothing in these files can answer
|
|
94
|
+
* that. Tests supply their own so the policy stays runnable without the binary.
|
|
95
|
+
*/
|
|
96
|
+
export interface CargoHakariShell {
|
|
97
|
+
run(directory: string, args: readonly string[]): { code: number; output: string; missing: boolean };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultHakariShell: CargoHakariShell = {
|
|
101
|
+
run(directory, args) {
|
|
102
|
+
const result = spawnSync('cargo', ['hakari', ...args], { cwd: directory, encoding: 'utf8' });
|
|
103
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
104
|
+
// cargo itself exists; a missing subcommand is cargo's own error, not ENOENT.
|
|
105
|
+
const missing =
|
|
106
|
+
(result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' || output.includes('no such command');
|
|
107
|
+
return { code: result.status ?? 1, output, missing };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Cargo's nightly-only workspace feature unification, and the config that turns it on. */
|
|
112
|
+
const RESOLVER_UNIFICATION_BLOCK = [
|
|
113
|
+
'# One feature resolution for the whole workspace. Without it every per-crate',
|
|
114
|
+
'# cargo invocation resolves features for its own selection, so one shared',
|
|
115
|
+
'# dependency is compiled once per selection and no warm target directory is',
|
|
116
|
+
'# reused across crates. Requires the nightly channel the managed devenv',
|
|
117
|
+
'# module pins; on a stable toolchain a cargo-hakari workspace-hack does the',
|
|
118
|
+
'# same job. Both keys are needed: cargo ignores [resolver] without the',
|
|
119
|
+
'# [unstable] opt-in.',
|
|
120
|
+
'[unstable]',
|
|
121
|
+
'feature-unification = true',
|
|
122
|
+
'',
|
|
123
|
+
'[resolver]',
|
|
124
|
+
'feature-unification = "workspace"',
|
|
125
|
+
].join('\n');
|
|
65
126
|
|
|
66
127
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
|
67
128
|
'node_modules',
|
|
@@ -256,6 +317,188 @@ function hasAncestorWorkspace(manifest: LoadedManifest, workspaceRoots: LoadedMa
|
|
|
256
317
|
);
|
|
257
318
|
}
|
|
258
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The channel that will actually compile this repository.
|
|
322
|
+
*
|
|
323
|
+
* The managed devenv module wins when it exists: devenv resolves the toolchain
|
|
324
|
+
* through rust-overlay and ignores `rust-toolchain.toml` unless
|
|
325
|
+
* `languages.rust.toolchainFile` names it, so a rust-toolchain file beside that
|
|
326
|
+
* module is decoration. Without the module, rustup's file is the answer. With
|
|
327
|
+
* neither, stable is the safe verdict: it is the channel on which the nightly
|
|
328
|
+
* resolver keys silently do nothing.
|
|
329
|
+
*/
|
|
330
|
+
function isNightlyToolchain(repositoryRoot: string, workspaceDirectory: string): boolean {
|
|
331
|
+
const devenvModule = join(repositoryRoot, 'tooling/direnv/devenv.smoo.nix');
|
|
332
|
+
if (existsSync(devenvModule)) {
|
|
333
|
+
const text = readFileSync(devenvModule, 'utf8');
|
|
334
|
+
const rustIndex = text.indexOf('languages.rust');
|
|
335
|
+
const channel = rustIndex === -1 ? null : /channel\s*=\s*"([^"]+)"/.exec(text.slice(rustIndex))?.[1];
|
|
336
|
+
return channel?.startsWith('nightly') === true;
|
|
337
|
+
}
|
|
338
|
+
for (const directory of [workspaceDirectory, repositoryRoot]) {
|
|
339
|
+
const path = join(directory, 'rust-toolchain.toml');
|
|
340
|
+
if (!existsSync(path)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const validation = validateRustToolchainFile(Bun.TOML.parse(readFileSync(path, 'utf8')));
|
|
345
|
+
return validation.success && validation.data.toolchain?.channel?.startsWith('nightly') === true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Cargo merges `.cargo/config.toml` from the invocation directory upward, with
|
|
355
|
+
* the deepest file winning. Only the configs at or above the workspace root can
|
|
356
|
+
* govern a command run there, so the effective value is the deepest of those.
|
|
357
|
+
*/
|
|
358
|
+
function effectiveConfigValue<T>(
|
|
359
|
+
configs: LoadedConfig[],
|
|
360
|
+
workspaceDirectory: string,
|
|
361
|
+
read: (config: CargoConfig) => T | undefined,
|
|
362
|
+
): T | undefined {
|
|
363
|
+
let deepest: { directory: string; value: T } | null = null;
|
|
364
|
+
for (const loaded of configs) {
|
|
365
|
+
const directory = dirname(dirname(loaded.path));
|
|
366
|
+
if (!pathWithin(directory, workspaceDirectory)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const value = read(loaded.config);
|
|
370
|
+
if (value === undefined) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (deepest === null || directory.length > deepest.directory.length) {
|
|
374
|
+
deepest = { directory, value };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return deepest?.value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function workspaceCrates(root: LoadedManifest, manifests: LoadedManifest[]): LoadedManifest[] {
|
|
381
|
+
const members = manifests.filter((manifest) => workspaceContains(root, manifest));
|
|
382
|
+
return root.manifest.package === undefined ? members : [root, ...members];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const FEATURE_UNIFICATION_FIX = 'Run: smoo monorepo update';
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A workspace of two or more crates must resolve features ONCE for all of them.
|
|
389
|
+
*
|
|
390
|
+
* Without that, every per-crate cargo invocation resolves features for its own
|
|
391
|
+
* selection, and one shared dependency is compiled once per selection: the same
|
|
392
|
+
* `nx run cargo-test-<crate>` that should reuse a warm target directory rebuilds
|
|
393
|
+
* its half of the graph instead. Cargo's own mechanism is nightly-only, so a
|
|
394
|
+
* stable toolchain reaches the identical result through a cargo-hakari
|
|
395
|
+
* workspace-hack — a generated crate that depends on the union of features, which
|
|
396
|
+
* every member then depends on.
|
|
397
|
+
*
|
|
398
|
+
* One crate needs neither: there is only one selection to unify.
|
|
399
|
+
*/
|
|
400
|
+
function featureUnificationPolicy(
|
|
401
|
+
repositoryRoot: string,
|
|
402
|
+
workspaceRoots: LoadedManifest[],
|
|
403
|
+
manifests: LoadedManifest[],
|
|
404
|
+
configs: LoadedConfig[],
|
|
405
|
+
shell: CargoHakariShell,
|
|
406
|
+
): number {
|
|
407
|
+
let failures = 0;
|
|
408
|
+
for (const root of workspaceRoots) {
|
|
409
|
+
const crates = workspaceCrates(root, manifests);
|
|
410
|
+
if (crates.length < 2) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const nightly = isNightlyToolchain(repositoryRoot, root.directory);
|
|
414
|
+
const resolver = effectiveConfigValue(
|
|
415
|
+
configs,
|
|
416
|
+
root.directory,
|
|
417
|
+
(config) => config.resolver?.['feature-unification'],
|
|
418
|
+
);
|
|
419
|
+
const unstable = effectiveConfigValue(
|
|
420
|
+
configs,
|
|
421
|
+
root.directory,
|
|
422
|
+
(config) => config.unstable?.['feature-unification'],
|
|
423
|
+
);
|
|
424
|
+
if (nightly && resolver === 'workspace') {
|
|
425
|
+
if (unstable !== true) {
|
|
426
|
+
failures += report(
|
|
427
|
+
join(root.directory, '.cargo/config.toml'),
|
|
428
|
+
'sets [resolver] feature-unification = "workspace" without [unstable] feature-unification = true, ' +
|
|
429
|
+
`so cargo ignores it and every crate still resolves features on its own. ${FEATURE_UNIFICATION_FIX}`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (existsSync(join(root.directory, '.config/hakari.toml'))) {
|
|
435
|
+
failures += hakariWorkspaceHackPolicy(root, crates, shell);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
failures += report(
|
|
439
|
+
root.path,
|
|
440
|
+
`workspace of ${crates.length} crates has no workspace-wide feature unification: ` +
|
|
441
|
+
(nightly
|
|
442
|
+
? 'add [unstable] feature-unification = true and [resolver] feature-unification = "workspace" to .cargo/config.toml'
|
|
443
|
+
: 'the [resolver] feature-unification key needs the nightly channel, so this stable toolchain needs a ' +
|
|
444
|
+
'cargo-hakari workspace-hack (.config/hakari.toml)') +
|
|
445
|
+
`. Every per-crate cargo invocation otherwise recompiles the shared graph for its own feature selection. ${FEATURE_UNIFICATION_FIX}`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return failures;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function hakariWorkspaceHackPolicy(root: LoadedManifest, crates: LoadedManifest[], shell: CargoHakariShell): number {
|
|
452
|
+
const configPath = join(root.directory, '.config/hakari.toml');
|
|
453
|
+
let hackName: string | undefined;
|
|
454
|
+
try {
|
|
455
|
+
const validation = validateHakariConfig(Bun.TOML.parse(readFileSync(configPath, 'utf8')));
|
|
456
|
+
hackName = validation.success ? validation.data['hakari-package'] : undefined;
|
|
457
|
+
} catch {
|
|
458
|
+
hackName = undefined;
|
|
459
|
+
}
|
|
460
|
+
if (hackName === undefined || hackName.length === 0) {
|
|
461
|
+
return report(configPath, `must declare hakari-package = "<crate>". ${FEATURE_UNIFICATION_FIX}`);
|
|
462
|
+
}
|
|
463
|
+
const hack = crates.find((crate) => crate.manifest.package?.name === hackName);
|
|
464
|
+
if (hack === undefined) {
|
|
465
|
+
return report(
|
|
466
|
+
configPath,
|
|
467
|
+
`names hakari-package "${hackName}", which is not a member of this workspace. ${FEATURE_UNIFICATION_FIX}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const unwired = crates
|
|
471
|
+
.filter((crate) => crate !== hack)
|
|
472
|
+
.filter(
|
|
473
|
+
(crate) =>
|
|
474
|
+
![crate.manifest.dependencies, crate.manifest['dev-dependencies'], crate.manifest['build-dependencies']].some(
|
|
475
|
+
(table) => table !== undefined && hackName in table,
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
.map((crate) => crate.manifest.package?.name ?? crate.path);
|
|
479
|
+
if (unwired.length > 0) {
|
|
480
|
+
return report(
|
|
481
|
+
root.path,
|
|
482
|
+
`these crates do not depend on "${hackName}", so cargo-hakari cannot unify their features: ` +
|
|
483
|
+
`${unwired.join(', ')}. ${FEATURE_UNIFICATION_FIX}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
const verify = shell.run(root.directory, ['verify']);
|
|
487
|
+
if (verify.missing) {
|
|
488
|
+
return report(
|
|
489
|
+
configPath,
|
|
490
|
+
'needs cargo-hakari, which is not installed; the managed devenv module provides it — reload the shell (direnv reload).',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (verify.code !== 0) {
|
|
494
|
+
return report(
|
|
495
|
+
root.path,
|
|
496
|
+
`cargo hakari verify rejected the workspace-hack: ${verify.output || 'no output'}. ${FEATURE_UNIFICATION_FIX}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
259
502
|
function isWorkspaceMember(manifest: LoadedManifest, workspaceRoots: LoadedManifest[]): boolean {
|
|
260
503
|
if (manifest.manifest.package?.workspace !== undefined) {
|
|
261
504
|
return true;
|
|
@@ -563,7 +806,12 @@ function reportManifestDirectoryAdvisories(repositoryRoot: string, ignoredDirect
|
|
|
563
806
|
}
|
|
564
807
|
}
|
|
565
808
|
|
|
566
|
-
export
|
|
809
|
+
export interface CargoPolicyOptions {
|
|
810
|
+
/** Injected in tests; production shells out to the real `cargo hakari`. */
|
|
811
|
+
shell?: CargoHakariShell;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function validateCargoCachePolicy(root: string, options: CargoPolicyOptions = {}): number {
|
|
567
815
|
const repositoryRoot = resolve(root);
|
|
568
816
|
const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
|
|
569
817
|
const ignoredDirectories = discoverIgnoredSubtrees(discoveredManifestPaths);
|
|
@@ -606,6 +854,13 @@ export function validateCargoCachePolicy(root: string): number {
|
|
|
606
854
|
|
|
607
855
|
const workspaceRoots = manifests.filter((loaded) => loaded.manifest.workspace !== undefined);
|
|
608
856
|
failures += reportManifestPolicy(manifests, workspaceRoots);
|
|
857
|
+
failures += featureUnificationPolicy(
|
|
858
|
+
repositoryRoot,
|
|
859
|
+
workspaceRoots,
|
|
860
|
+
manifests,
|
|
861
|
+
configs,
|
|
862
|
+
options.shell ?? defaultHakariShell,
|
|
863
|
+
);
|
|
609
864
|
failures += cargoIncrementalPolicy(repositoryRoot, configs, packageJsonPaths, justfilePaths, ignoredDirectories);
|
|
610
865
|
for (const config of configs) {
|
|
611
866
|
failures += configPolicy(config, repositoryRoot);
|
|
@@ -613,3 +868,87 @@ export function validateCargoCachePolicy(root: string): number {
|
|
|
613
868
|
reportManifestDirectoryAdvisories(repositoryRoot, ignoredDirectories);
|
|
614
869
|
return failures;
|
|
615
870
|
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* The fix `smoo monorepo update` applies for the policy above: write the
|
|
874
|
+
* nightly resolver configuration, or generate and wire the workspace-hack a
|
|
875
|
+
* stable toolchain needs. Both are idempotent — the second run of either does
|
|
876
|
+
* nothing — because update runs on every repository, not only broken ones.
|
|
877
|
+
*/
|
|
878
|
+
export function applyCargoFeatureUnification(root: string, options: CargoPolicyOptions = {}): void {
|
|
879
|
+
const repositoryRoot = resolve(root);
|
|
880
|
+
const shell = options.shell ?? defaultHakariShell;
|
|
881
|
+
const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
|
|
882
|
+
const manifestPaths = filterIgnoredPaths(discoveredManifestPaths, discoverIgnoredSubtrees(discoveredManifestPaths));
|
|
883
|
+
const manifests: LoadedManifest[] = [];
|
|
884
|
+
for (const path of manifestPaths) {
|
|
885
|
+
const manifest = loadManifest(path);
|
|
886
|
+
if (manifest !== null) {
|
|
887
|
+
manifests.push({ path, directory: dirname(path), manifest });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const root of manifests.filter((loaded) => loaded.manifest.workspace !== undefined)) {
|
|
891
|
+
if (workspaceCrates(root, manifests).length < 2) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (isNightlyToolchain(repositoryRoot, root.directory)) {
|
|
895
|
+
writeResolverUnification(root.directory);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const hakariConfig = join(root.directory, '.config/hakari.toml');
|
|
899
|
+
const relativeRoot = relative(repositoryRoot, root.directory) || '.';
|
|
900
|
+
if (!existsSync(hakariConfig)) {
|
|
901
|
+
console.log(`generating cargo workspace-hack in ${relativeRoot} (cargo hakari init workspace-hack)`);
|
|
902
|
+
runHakari(shell, root.directory, ['init', 'workspace-hack']);
|
|
903
|
+
}
|
|
904
|
+
console.log(`unifying cargo features in ${relativeRoot} (cargo hakari generate, manage-deps)`);
|
|
905
|
+
runHakari(shell, root.directory, ['generate']);
|
|
906
|
+
runHakari(shell, root.directory, ['manage-deps', '--yes']);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function runHakari(shell: CargoHakariShell, directory: string, args: readonly string[]): void {
|
|
911
|
+
const result = shell.run(directory, args);
|
|
912
|
+
if (result.missing) {
|
|
913
|
+
console.error(
|
|
914
|
+
`cargo hakari is not installed, so the workspace-hack in ${directory} was not updated; the managed devenv module provides it — reload the shell (direnv reload).`,
|
|
915
|
+
);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (result.code !== 0) {
|
|
919
|
+
console.error(`cargo hakari ${args.join(' ')} failed in ${directory}: ${result.output || 'no output'}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Append the two keys, never rewrite the file. A `.cargo/config.toml` carries
|
|
925
|
+
* linkers, env and target settings whose ORDER and comments are load-bearing;
|
|
926
|
+
* re-emitting parsed TOML would lose both. If either table already exists with
|
|
927
|
+
* some other content, appending a second one is invalid TOML — so that case is
|
|
928
|
+
* reported for a human instead of being guessed at.
|
|
929
|
+
*/
|
|
930
|
+
function writeResolverUnification(workspaceDirectory: string): void {
|
|
931
|
+
const path = join(workspaceDirectory, '.cargo/config.toml');
|
|
932
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
933
|
+
if (existing === null) {
|
|
934
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
935
|
+
writeFileSync(path, `${RESOLVER_UNIFICATION_BLOCK}\n`);
|
|
936
|
+
console.log(`writing ${path}`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const parsed = loadConfig(path);
|
|
940
|
+
if (parsed === null) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (parsed.resolver?.['feature-unification'] === 'workspace' && parsed.unstable?.['feature-unification'] === true) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (parsed.resolver !== undefined || parsed.unstable !== undefined) {
|
|
947
|
+
console.error(
|
|
948
|
+
`${path}: already declares [resolver] or [unstable]; add feature-unification to the existing tables by hand:\n${RESOLVER_UNIFICATION_BLOCK}`,
|
|
949
|
+
);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
writeFileSync(path, `${existing.replace(/\n*$/, '\n')}\n${RESOLVER_UNIFICATION_BLOCK}\n`);
|
|
953
|
+
console.log(`updating ${path}`);
|
|
954
|
+
}
|
package/src/monorepo/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { printCommandOutput, run, runResult } from '../lib/run.js';
|
|
3
3
|
import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
|
|
4
4
|
import { readProjectTargets } from '../nx/index.js';
|
|
5
|
-
import { validateCargoCachePolicy } from './cargo-policy.js';
|
|
5
|
+
import { applyCargoFeatureUnification, validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
6
|
import {
|
|
7
7
|
formatCommitMessage,
|
|
8
8
|
stagedDeletedPublicPackages,
|
|
@@ -112,6 +112,10 @@ export async function updateManagedFiles(root: string): Promise<void> {
|
|
|
112
112
|
// Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
|
|
113
113
|
// lives next to managed templates — update must install them, not only rewrite files.
|
|
114
114
|
await applyToolConfigDefaults(root);
|
|
115
|
+
// Rust's half of the same job: a multi-crate Cargo workspace must resolve
|
|
116
|
+
// features once for every member, or each per-crate cargo invocation
|
|
117
|
+
// recompiles the shared graph for its own selection.
|
|
118
|
+
applyCargoFeatureUnification(root);
|
|
115
119
|
syncBunLockfileVersions(root, { mode: 'install' });
|
|
116
120
|
console.log('installing workspace dependencies (bun install)');
|
|
117
121
|
await run('bun', ['install', '--no-summary'], root);
|
|
@@ -221,6 +221,38 @@ describe('monorepo validation pack phases', () => {
|
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it('validates and fixes cargo workspace feature unification through its own pack', async () => {
|
|
225
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-validate-cargo-'));
|
|
226
|
+
try {
|
|
227
|
+
await mkdir(join(root, 'crates/alpha'), { recursive: true });
|
|
228
|
+
await mkdir(join(root, 'crates/beta'), { recursive: true });
|
|
229
|
+
await mkdir(join(root, 'tooling/direnv'), { recursive: true });
|
|
230
|
+
await writeFile(
|
|
231
|
+
join(root, 'Cargo.toml'),
|
|
232
|
+
'[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
233
|
+
);
|
|
234
|
+
await writeFile(join(root, 'crates/alpha/Cargo.toml'), '[package]\nname = "alpha"\n');
|
|
235
|
+
await writeFile(join(root, 'crates/beta/Cargo.toml'), '[package]\nname = "beta"\n');
|
|
236
|
+
await writeFile(join(root, 'tooling/direnv/devenv.smoo.nix'), 'languages.rust = {\n channel = "nightly";\n};\n');
|
|
237
|
+
const cargoPack = packsForTest.find((pack) => pack.name === 'cargo');
|
|
238
|
+
if (!cargoPack) {
|
|
239
|
+
throw new Error('cargo validation pack not found');
|
|
240
|
+
}
|
|
241
|
+
const runBuild = () => 0;
|
|
242
|
+
|
|
243
|
+
// A policy nothing calls is not a policy: validate must reach it.
|
|
244
|
+
expect(
|
|
245
|
+
await runValidatePacks({ root, syncRuntime: false }, { failFast: true }, { packs: [cargoPack], runBuild }),
|
|
246
|
+
).toEqual({ failures: 1, failedChecks: 1 });
|
|
247
|
+
|
|
248
|
+
expect(
|
|
249
|
+
await runValidatePacks({ root, syncRuntime: false }, { fix: true }, { packs: [cargoPack], runBuild }),
|
|
250
|
+
).toEqual({ failures: 0, failedChecks: 0 });
|
|
251
|
+
} finally {
|
|
252
|
+
await rm(root, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
224
256
|
it('propagates parsed target dependencies through the production adapter', () => {
|
|
225
257
|
const targetDependencies = new Map([
|
|
226
258
|
['build', ['compile-linux']],
|