@smoothbricks/cli 0.11.10 → 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.
@@ -1,4 +1,5 @@
1
- import { type Dirent, existsSync, readdirSync, readFileSync } from 'node:fs';
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 function validateCargoCachePolicy(root: string): number {
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
+ }
@@ -140,7 +140,7 @@ interface InstallRun {
140
140
  nixCalls: string;
141
141
  }
142
142
 
143
- function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}): InstallRun {
143
+ function runInstallDevenv(lock: string, options: { devenvVersion?: string; hostRunner?: boolean } = {}): InstallRun {
144
144
  const dir = mkdtempSync(join(tmpdir(), 'gab-install-'));
145
145
  try {
146
146
  const direnv = join(dir, 'root', 'tooling', 'direnv');
@@ -154,20 +154,22 @@ function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}
154
154
  const stub = join(bin, 'nix');
155
155
  writeFileSync(stub, NIX_STUB);
156
156
  chmodSync(stub, 0o755);
157
- if (options.devenvOnPath) {
157
+ if (options.devenvVersion !== undefined) {
158
+ // Shaped like the real thing: `devenv <semver>+<short rev> (<system>)`.
158
159
  const devenv = join(bin, 'devenv');
159
- writeFileSync(devenv, '#!/usr/bin/env bash\necho 9.9.9-from-path\n');
160
+ writeFileSync(devenv, `#!/usr/bin/env bash\necho "${options.devenvVersion}"\n`);
160
161
  chmodSync(devenv, 0o755);
161
162
  }
162
163
  const r = spawnSync('bash', [join(direnv, 'github-actions-bootstrap.sh'), 'install-devenv'], {
163
164
  encoding: 'utf8',
164
- // A bare PATH on purpose: an ambient devenv would select the
165
- // already-installed branch and the pin would never be exercised.
165
+ // A bare PATH on purpose: an ambient devenv would decide these cases
166
+ // instead of the stub.
166
167
  env: {
167
168
  PATH: `${bin}:${dirname(REAL_NIX)}:/usr/bin:/bin`,
168
169
  HOME: join(dir, 'home'),
169
170
  NIX_CALLS: nixCalls,
170
171
  REAL_NIX,
172
+ ...(options.hostRunner ? { SMOO_HOST_RUNNER: 'true' } : {}),
171
173
  },
172
174
  });
173
175
  return {
@@ -181,10 +183,12 @@ function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}
181
183
  }
182
184
  }
183
185
 
186
+ const REV = 'f'.repeat(40);
187
+
184
188
  const LOCK_WITH_REV = JSON.stringify({
185
189
  nodes: {
186
190
  devenv: {
187
- locked: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', rev: 'f'.repeat(40), type: 'github' },
191
+ locked: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', rev: REV, type: 'github' },
188
192
  original: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', type: 'github' },
189
193
  },
190
194
  },
@@ -202,10 +206,10 @@ describe('github-actions-bootstrap install-devenv', () => {
202
206
  // The rev comes out of the lock, so the CLI is the commit whose modules the
203
207
  // shell is locked to. An unpinned `github:cachix/devenv` would install
204
208
  // whatever HEAD is on the day a cold runner misses the store cache.
205
- expect(run.nixCalls).toContain(`nix profile add --accept-flake-config github:cachix/devenv/${'f'.repeat(40)}`);
209
+ expect(run.nixCalls).toContain(`nix profile add --accept-flake-config github:cachix/devenv/${REV}`);
206
210
  expect(run.nixCalls).not.toContain('github:cachix/devenv\n');
207
211
  // Announced, so a run's log names the version it installed.
208
- expect(run.stdout).toContain(`github:cachix/devenv/${'f'.repeat(40)}`);
212
+ expect(run.stdout).toContain(`github:cachix/devenv/${REV}`);
209
213
  });
210
214
 
211
215
  it('refuses to install anything when the lock names no devenv rev', () => {
@@ -216,10 +220,60 @@ describe('github-actions-bootstrap install-devenv', () => {
216
220
  expect(run.nixCalls).not.toContain('profile add');
217
221
  });
218
222
 
219
- it('keeps an already-installed devenv and never evaluates the flake', () => {
220
- const run = runInstallDevenv(LOCK_WITH_REV, { devenvOnPath: true });
223
+ it('keeps a restored devenv built from the locked commit', () => {
224
+ const run = runInstallDevenv(LOCK_WITH_REV, { devenvVersion: 'devenv 2.3.1+fffffff (aarch64-darwin)' });
225
+ expect(run.status).toBe(0);
226
+ expect(run.stdout).toContain('using locked devenv');
227
+ // The warm path must cost nothing: no eval of the flake, no profile writes.
228
+ expect(run.nixCalls).toBe('');
229
+ });
230
+
231
+ it('replaces a restored devenv that is a different commit than the lock', () => {
232
+ // The real drift: the store cache restores ~/.nix-profile wholesale, so a
233
+ // rotated key hands the job the CLI of whichever run last populated the
234
+ // prefix. Observed in run 34369909403 as devenv 2.3.1+2a399e9 against a
235
+ // lock naming 190959a.
236
+ const run = runInstallDevenv(LOCK_WITH_REV, { devenvVersion: 'devenv 2.3.1+2a399e9 (aarch64-darwin)' });
237
+ if (run.status !== 0) {
238
+ printCommandOutput(run.stdout, run.stderr);
239
+ }
240
+ expect(run.status).toBe(0);
241
+ // Removal first: `nix profile add` collides on bin/devenv at equal priority.
242
+ const removeAt = run.nixCalls.indexOf('profile remove --all');
243
+ const addAt = run.nixCalls.indexOf(`profile add --accept-flake-config github:cachix/devenv/${REV}`);
244
+ expect(removeAt).toBeGreaterThanOrEqual(0);
245
+ expect(addAt).toBeGreaterThan(removeAt);
246
+ // Said out loud, with both commits, so a log explains the replacement.
247
+ expect(run.stdout).toContain('replacing devenv');
248
+ expect(run.stdout).toContain('2a399e9');
249
+ });
250
+
251
+ it('leaves a host runner its own devenv, whatever commit it is', () => {
252
+ // Host runners own their Nix profile; a repository rewriting it would take
253
+ // the whole fleet's shared store with it.
254
+ const run = runInstallDevenv(LOCK_WITH_REV, {
255
+ devenvVersion: 'devenv 2.3.1+2a399e9 (x86_64-linux)',
256
+ hostRunner: true,
257
+ });
221
258
  expect(run.status).toBe(0);
222
- expect(run.stdout).toContain('using existing devenv');
259
+ expect(run.stdout).toContain('using host devenv');
223
260
  expect(run.nixCalls).toBe('');
224
261
  });
262
+
263
+ it('installs from the floating branch on a host runner with no devenv', () => {
264
+ // The one place drift is accepted deliberately. A host installs into a
265
+ // long-lived profile that roots the fleet's shared store, and holding it
266
+ // back to an older CLI than the fleet was running segfaulted the Rust
267
+ // linker in run 34374650577. Refusing to install at all was also wrong —
268
+ // that failed linux-release-candidate in run 34373420924.
269
+ const run = runInstallDevenv(LOCK_WITH_REV, { hostRunner: true });
270
+ if (run.status !== 0) {
271
+ printCommandOutput(run.stdout, run.stderr);
272
+ }
273
+ expect(run.status).toBe(0);
274
+ expect(run.nixCalls).toContain('nix profile add --accept-flake-config github:cachix/devenv\n');
275
+ expect(run.nixCalls).not.toContain(REV);
276
+ // Never on a host: the profile roots a store the whole fleet shares.
277
+ expect(run.nixCalls).not.toContain('profile remove');
278
+ });
225
279
  });
@@ -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']],
@@ -2,6 +2,7 @@ import { chmodSync, existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { printCommandOutput, runResult, runStatus } from '../../lib/run.js';
4
4
  import { type ProjectTargets, readProjectTargets } from '../../nx/index.js';
5
+ import { applyCargoFeatureUnification, validateCargoCachePolicy } from '../cargo-policy.js';
5
6
  import { validateGoToolchainAgreement } from '../go-toolchain.js';
6
7
  import { syncBunLockfileVersions, validateBunLockfileVersions } from '../lockfile.js';
7
8
  import { validateDevenvModuleImport, warnOnManagedFileDrift } from '../managed-files.js';
@@ -120,6 +121,18 @@ const packs: MonorepoPack[] = [
120
121
  return validateDevenvModuleImport(ctx.root);
121
122
  },
122
123
  },
124
+ {
125
+ // Cargo's build-cache and feature-unification conventions. `--fix` writes
126
+ // the workspace feature unification a multi-crate workspace needs; the rest
127
+ // of the policy is a verdict a human has to act on.
128
+ name: 'cargo',
129
+ fixPreBuild(ctx) {
130
+ applyCargoFeatureUnification(ctx.root);
131
+ },
132
+ validatePreBuild(ctx) {
133
+ return validateCargoCachePolicy(ctx.root);
134
+ },
135
+ },
123
136
  {
124
137
  name: 'publishing',
125
138
  init(ctx) {