@smoothbricks/cli 0.10.6 → 0.10.7

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.
Files changed (45) hide show
  1. package/dist/cli.js +1 -0
  2. package/dist/github-ci/index.d.ts +1 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +23 -2
  5. package/dist/lib/json.d.ts +1 -1
  6. package/dist/lib/json.d.ts.map +1 -1
  7. package/dist/lib/json.js +3 -18
  8. package/dist/lib/workspace.d.ts.map +1 -1
  9. package/dist/lib/workspace.js +1 -0
  10. package/dist/monorepo/managed-files.d.ts +6 -22
  11. package/dist/monorepo/managed-files.d.ts.map +1 -1
  12. package/dist/monorepo/managed-files.js +12 -47
  13. package/dist/monorepo/package-policy.js +2 -2
  14. package/dist/monorepo/packs/index.d.ts.map +1 -1
  15. package/dist/monorepo/packs/index.js +2 -0
  16. package/dist/monorepo/publish-workflow.js +1 -1
  17. package/dist/monorepo/runtime.d.ts +2 -2
  18. package/dist/monorepo/runtime.d.ts.map +1 -1
  19. package/dist/monorepo/runtime.js +11 -16
  20. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  21. package/dist/monorepo/tool-validation.js +20 -23
  22. package/dist/nx/index.d.ts +7 -4
  23. package/dist/nx/index.d.ts.map +1 -1
  24. package/dist/nx/index.js +117 -56
  25. package/managed/raw/tooling/devenv +71 -0
  26. package/package.json +4 -4
  27. package/src/cli.ts +8 -1
  28. package/src/github-ci/index.test.ts +50 -1
  29. package/src/github-ci/index.ts +31 -2
  30. package/src/lib/json.ts +1 -1
  31. package/src/lib/workspace.ts +1 -0
  32. package/src/monorepo/__tests__/publish-workflow.test.ts +11 -3
  33. package/src/monorepo/managed-files.test.ts +31 -22
  34. package/src/monorepo/managed-files.ts +15 -68
  35. package/src/monorepo/package-policy.test.ts +31 -0
  36. package/src/monorepo/package-policy.ts +2 -2
  37. package/src/monorepo/packs/index.ts +2 -0
  38. package/src/monorepo/publish-workflow.ts +5 -0
  39. package/src/monorepo/runtime.test.ts +23 -17
  40. package/src/monorepo/runtime.ts +12 -17
  41. package/src/monorepo/tool-validation.test.ts +15 -8
  42. package/src/monorepo/tool-validation.ts +20 -25
  43. package/src/nx/index.test.ts +45 -14
  44. package/src/nx/index.ts +133 -64
  45. package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
@@ -4,17 +4,17 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
6
  import fc from 'fast-check';
7
+ import { type NxProjects, targetNamesFromProjects } from '../nx/index.js';
7
8
  import {
8
9
  deployTargetInfoFromProjects,
9
10
  extractInlineLocalBlocksForTest,
10
11
  INLINE_LOCAL_BEGIN,
11
12
  INLINE_LOCAL_END,
12
13
  LOCAL_SECTION_MARKER,
13
- type NxGraphProjectNode,
14
+ managedFileTargetsForTest,
14
15
  platformTargetGlobsForTest,
15
16
  reinsertInlineLocalBlocksForTest,
16
17
  splitLocalSectionForTest,
17
- targetNamesFromProjects,
18
18
  } from './managed-files.js';
19
19
 
20
20
  const MANAGED = '# managed content\npath merge=driver\n';
@@ -176,48 +176,57 @@ describe('managed publish platform discovery', () => {
176
176
  });
177
177
 
178
178
  describe('nx graph project helpers', () => {
179
- const sampleNodes: Record<string, NxGraphProjectNode> = {
179
+ const sampleProjects: NxProjects = {
180
180
  lib: {
181
- data: {
182
- targets: {
183
- build: {},
184
- 'bundle-linux': {},
185
- lint: {},
186
- },
181
+ targets: {
182
+ build: {},
183
+ 'bundle-linux': {},
184
+ lint: {},
187
185
  },
188
186
  },
189
187
  app: {
190
- data: {
191
- targets: {
192
- deploy: {
193
- options: { command: 'echo no' },
194
- configurations: {
195
- staging: { command: 'wrangler deploy --env staging' },
196
- production: { options: { command: 'wrangler deploy --env production' } },
197
- },
188
+ targets: {
189
+ deploy: {
190
+ options: { command: 'echo no' },
191
+ configurations: {
192
+ staging: { command: 'wrangler deploy --env staging' },
193
+ production: { options: { command: 'wrangler deploy --env production' } },
198
194
  },
199
- 'package-macos': {},
200
195
  },
196
+ 'package-macos': {},
201
197
  },
202
198
  },
203
199
  };
204
200
 
205
201
  it('collects target names from graph nodes without project names', () => {
206
- expect(targetNamesFromProjects(sampleNodes).sort()).toEqual(
202
+ expect(targetNamesFromProjects(sampleProjects).sort()).toEqual(
207
203
  ['build', 'bundle-linux', 'deploy', 'lint', 'package-macos'].sort(),
208
204
  );
209
205
  });
210
206
 
211
207
  it('detects deploy configurations and cloudflare provider from graph nodes', () => {
212
- expect(deployTargetInfoFromProjects(sampleNodes, 'staging')).toEqual({
208
+ expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({
213
209
  exists: true,
214
210
  provider: 'cloudflare',
215
211
  });
216
- expect(deployTargetInfoFromProjects(sampleNodes, 'production')).toEqual({
212
+ expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({
217
213
  exists: true,
218
214
  provider: 'cloudflare',
219
215
  });
220
- expect(deployTargetInfoFromProjects(sampleNodes, 'preview')).toEqual({ exists: false });
216
+ expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
217
+ });
218
+ });
219
+
220
+ describe('managed raw files', () => {
221
+ it('manages the devenv wrapper as an executable byte-exact copy', async () => {
222
+ expect(managedFileTargetsForTest).toContainEqual({ target: 'tooling/devenv', executable: true });
223
+
224
+ const [source, generated] = await Promise.all([
225
+ readFile(join(REPO_ROOT, 'packages', 'cli', 'managed', 'raw', 'tooling', 'devenv'), 'utf8'),
226
+ readFile(join(REPO_ROOT, 'tooling', 'devenv'), 'utf8'),
227
+ ]);
228
+
229
+ expect(generated).toBe(source);
221
230
  });
222
231
  });
223
232
 
@@ -2,10 +2,9 @@ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFi
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
5
- // Nx package exports are CLI-oriented; this is the module the CLI uses for graph + daemon IPC.
6
- import { createProjectGraphAsync } from 'nx/src/project-graph/project-graph.js';
7
- import type { PackageJson } from '../lib/json.js';
5
+ import type { NxTargetConfig, PackageJson } from '../lib/json.js';
8
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
7
+ import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
9
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
10
9
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
11
10
 
@@ -155,6 +154,12 @@ const managedFiles: ManagedFile[] = [
155
154
  source: 'envrc',
156
155
  target: '.envrc',
157
156
  },
157
+ {
158
+ kind: 'raw',
159
+ source: 'tooling/devenv',
160
+ target: 'tooling/devenv',
161
+ executable: true,
162
+ },
158
163
  {
159
164
  kind: 'raw',
160
165
  source: 'tooling/direnv/repo-path',
@@ -201,11 +206,6 @@ const managedFiles: ManagedFile[] = [
201
206
  target: 'tooling/direnv/merge-newer-pins.sh',
202
207
  executable: true,
203
208
  },
204
- {
205
- kind: 'raw',
206
- source: 'patches/ttsc@0.19.3.patch',
207
- target: 'patches/ttsc@0.19.3.patch',
208
- },
209
209
  {
210
210
  kind: 'generated',
211
211
  source: 'ci-workflow',
@@ -254,6 +254,8 @@ const managedFiles: ManagedFile[] = [
254
254
  },
255
255
  ];
256
256
 
257
+ export const managedFileTargetsForTest = managedFiles.map(({ target, executable }) => ({ target, executable }));
258
+
257
259
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
258
260
 
259
261
  export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
@@ -337,7 +339,7 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
337
339
  const ciPushBranches = getCiPushBranches(packageJson?.json);
338
340
  const ciRunsOn = getCiRunsOn(packageJson?.json);
339
341
  // In-process Nx API → daemon socket (no second Node/`nx` CLI process).
340
- const nxProjects = await loadNxGraphProjects(root);
342
+ const nxProjects = await loadNxProjects(root);
341
343
  const stagingDeploy = deployTargetInfoFromProjects(nxProjects, 'staging');
342
344
  const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
343
345
  const platformTargetGlobs = platformTargetGlobsForTest(targetNamesFromProjects(nxProjects));
@@ -366,67 +368,12 @@ export function platformTargetGlobsForTest(targetNames: Iterable<string>): strin
366
368
  });
367
369
  }
368
370
 
369
- /** Resolved Nx project node as returned under `graph.nodes` / project-graph.json. */
370
- export type NxGraphProjectNode = {
371
- data?: { targets?: Record<string, NxGraphTarget> };
372
- targets?: Record<string, NxGraphTarget>;
373
- };
374
-
375
- type NxGraphTarget = {
376
- command?: string;
377
- options?: { command?: string };
378
- configurations?: Record<string, { command?: string; options?: { command?: string } }>;
379
- };
380
-
381
- /**
382
- * Load resolved project nodes via Nx's programmatic API.
383
- *
384
- * `createProjectGraphAsync` is what the CLI uses: with the daemon up it speaks the
385
- * daemon unix socket instead of rebuilding the graph in a fresh Node process.
386
- * Spawning `nx graph` / `nx show` only added CLI boot + plugin-worker overhead on
387
- * top of that. smoo runs under Bun; Bun can import this CJS module directly.
388
- */
389
- async function loadNxGraphProjects(root: string): Promise<Record<string, NxGraphProjectNode>> {
390
- const prevCwd = process.cwd();
391
- process.chdir(root);
392
- try {
393
- const graph = (await createProjectGraphAsync()) as {
394
- nodes: Record<string, { data?: { targets?: Record<string, NxGraphTarget> } }>;
395
- };
396
- const out: Record<string, NxGraphProjectNode> = {};
397
- for (const [name, node] of Object.entries(graph.nodes)) {
398
- out[name] = { data: { targets: node.data?.targets ?? {} } };
399
- }
400
- return out;
401
- } finally {
402
- process.chdir(prevCwd);
403
- }
404
- }
405
-
406
- function targetsOfProject(node: NxGraphProjectNode): Record<string, NxGraphTarget> {
407
- return node.data?.targets ?? node.targets ?? {};
408
- }
409
-
410
- /** Test seam: collect target names from a graph.nodes map. */
411
- export function targetNamesFromProjects(nodes: Record<string, NxGraphProjectNode>): string[] {
412
- const targetNames = new Set<string>();
413
- for (const node of Object.values(nodes)) {
414
- for (const name of Object.keys(targetsOfProject(node))) {
415
- targetNames.add(name);
416
- }
417
- }
418
- return [...targetNames];
419
- }
420
-
421
371
  /** Test seam: deploy configuration presence/provider from graph nodes. */
422
- export function deployTargetInfoFromProjects(
423
- nodes: Record<string, NxGraphProjectNode>,
424
- configuration: string,
425
- ): DeployTargetInfo {
372
+ export function deployTargetInfoFromProjects(projects: NxProjects, configuration: string): DeployTargetInfo {
426
373
  let exists = false;
427
374
  let provider: DeployTargetInfo['provider'];
428
- for (const node of Object.values(nodes)) {
429
- const info = deployTargetInfoFromTargets(targetsOfProject(node), configuration);
375
+ for (const project of Object.values(projects)) {
376
+ const info = deployTargetInfoFromTargets(project.targets ?? {}, configuration);
430
377
  if (!info.exists) {
431
378
  continue;
432
379
  }
@@ -436,7 +383,7 @@ export function deployTargetInfoFromProjects(
436
383
  return { exists, provider };
437
384
  }
438
385
 
439
- function deployTargetInfoFromTargets(targets: Record<string, NxGraphTarget>, configuration: string): DeployTargetInfo {
386
+ function deployTargetInfoFromTargets(targets: Record<string, NxTargetConfig>, configuration: string): DeployTargetInfo {
440
387
  const deploy = targets.deploy;
441
388
  if (!deploy) {
442
389
  return { exists: false };
@@ -11,6 +11,8 @@ import {
11
11
 
12
12
  const TIMEOUT_FLAG = `--timeout=${BOUNDED_TEST_PER_TEST_TIMEOUT_MS}`;
13
13
 
14
+ import { validateCommitMessage } from './commit-msg.js';
15
+
14
16
  import {
15
17
  applyFixableMonorepoDefaults,
16
18
  applyNxPluginDefaults,
@@ -245,6 +247,35 @@ describe('Nx project name policy', () => {
245
247
  await rm(root, { recursive: true, force: true });
246
248
  }
247
249
  });
250
+
251
+ it('uses nx.name from an exact workspace package as the commit scope', async () => {
252
+ const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
253
+ try {
254
+ await writeJson(join(root, 'package.json'), {
255
+ name: '@smoothbricks/codebase',
256
+ version: '0.0.0',
257
+ private: true,
258
+ workspaces: ['tooling'],
259
+ });
260
+ await writeJson(join(root, 'tooling/package.json'), {
261
+ name: '@smoothbricks/internal-tooling',
262
+ private: true,
263
+ nx: { name: 'tooling' },
264
+ });
265
+
266
+ const validScopes = listValidCommitScopes(root);
267
+ expect(validScopes).toEqual(new Set(['release', 'tooling']));
268
+ expect(validateCommitMessage('fix(tooling): repair tooling\n', { validScopes })).toBeNull();
269
+ expect(validateCommitMessage('fix(internal-tooling): repair tooling\n', { validScopes })).toContain(
270
+ 'Invalid conventional commit scope',
271
+ );
272
+ expect(validateCommitMessage('fix(arbitrary): repair tooling\n', { validScopes })).toContain(
273
+ 'Invalid conventional commit scope',
274
+ );
275
+ } finally {
276
+ await rm(root, { recursive: true, force: true });
277
+ }
278
+ });
248
279
  });
249
280
 
250
281
  describe('workspace package script policy', () => {
@@ -165,7 +165,7 @@ export function applyWorkspaceDependencyDefaults(root: string, options: PackageT
165
165
  } else {
166
166
  console.log('unchanged tsconfig.test.json policy');
167
167
  }
168
- if (applyWorkspaceBoundedTestTargetPolicy(root)) {
168
+ if (applyWorkspaceBoundedTestTargetPolicy(root, options)) {
169
169
  console.log('updated package test targets bounded execution policy');
170
170
  } else {
171
171
  console.log('unchanged package test targets bounded execution policy');
@@ -409,7 +409,7 @@ export function validateWorkspaceDependencies(root: string, options: PackageTarg
409
409
  console.error(`${issue.path}: ${issue.message}`);
410
410
  failures++;
411
411
  }
412
- for (const issue of checkWorkspaceBoundedTestTargetPolicy(root)) {
412
+ for (const issue of checkWorkspaceBoundedTestTargetPolicy(root, options)) {
413
413
  console.error(`${issue.path}: ${issue.message}`);
414
414
  failures++;
415
415
  }
@@ -414,10 +414,12 @@ export function resolvedTargetsByProject(projects: ProjectTargets[]): Map<string
414
414
  projects.map((project) => [
415
415
  project.project,
416
416
  {
417
+ ...(project.root ? { root: project.root } : {}),
417
418
  targets: new Set(project.targets),
418
419
  ...(project.buildDependsOn ? { buildDependsOn: project.buildDependsOn } : {}),
419
420
  ...(project.targetDependencies ? { targetDependencies: project.targetDependencies } : {}),
420
421
  ...(project.targetExecutors ? { targetExecutors: project.targetExecutors } : {}),
422
+ ...(project.targetOptions ? { targetOptions: project.targetOptions } : {}),
421
423
  ...(project.targetScripts ? { targetScripts: project.targetScripts } : {}),
422
424
  },
423
425
  ]),
@@ -785,6 +785,11 @@ function renderMacosPlatformSteps(options: PublishWorkflowDefinitionOptions): st
785
785
  ` "${githubExpression('runner.temp')}/macos-platform-outputs"`,
786
786
  '',
787
787
  ` # Step ${stepNumber++}`,
788
+ ' - name: 🧪 Unit test macOS and iOS packages',
789
+ ' run:',
790
+ ` smoo github-ci nx-run-many --targets test --projects-with-targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}"`,
791
+ '',
792
+ ` # Step ${stepNumber++}`,
788
793
  ' - name: 📤 Upload macOS platform outputs',
789
794
  ' uses: actions/upload-artifact@v7.0.1',
790
795
  ' with:',
@@ -3,16 +3,16 @@ import type { PackageJson } from '../lib/json.js';
3
3
  import { runtimeTypesRangeForPublishedVersions, validateRuntimePins } from './runtime.js';
4
4
 
5
5
  describe('runtimeTypesRangeForPublishedVersions', () => {
6
- test('uses the newest published minor within the installed Node major, never the ~major.0.0 floor', () => {
6
+ test('anchors a caret range at the newest published version in the installed Node major', () => {
7
7
  expect(
8
8
  runtimeTypesRangeForPublishedVersions('@types/node', '24.12.0', 'major', ['24.0.0', '24.12.4', '25.9.1']),
9
- ).toBe('~24.12.4');
9
+ ).toBe('^24.12.4');
10
10
  });
11
11
 
12
- test('falls back to latest published @types/node when the Node major is unpublished', () => {
13
- expect(
12
+ test('rejects fallback types from a different Node major', () => {
13
+ expect(() =>
14
14
  runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['24.12.4', '25.9.0', '25.9.1']),
15
- ).toBe('~25.9.1');
15
+ ).toThrow('runtime major 26');
16
16
  });
17
17
 
18
18
  test('uses the installed Bun version when @types/bun has published it', () => {
@@ -23,10 +23,10 @@ describe('runtimeTypesRangeForPublishedVersions', () => {
23
23
  expect(runtimeTypesRangeForPublishedVersions('@types/bun', '1.3.15', 'exact', ['1.3.13', '1.3.14'])).toBe('1.3.14');
24
24
  });
25
25
 
26
- test('ignores non-stable version strings when choosing the fallback', () => {
27
- expect(runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['25.9.1', '26.0.0-beta.1'])).toBe(
28
- '~25.9.1',
29
- );
26
+ test('ignores non-stable version strings when choosing the same-major anchor', () => {
27
+ expect(
28
+ runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['25.9.1', '26.0.2', '26.1.0-beta.1']),
29
+ ).toBe('^26.0.2');
30
30
  });
31
31
  });
32
32
 
@@ -35,23 +35,29 @@ describe('validateRuntimePins', () => {
35
35
  const aligned = () => ({
36
36
  engines: { node: '>=24.0.0' },
37
37
  packageManager: 'bun@1.3.14',
38
- devDependencies: { '@types/node': '~24.13.0' },
38
+ devDependencies: { '@types/node': '^24.13.0' },
39
39
  });
40
40
 
41
- test('passes when every pin agrees with the PATH runtimes', () => {
42
- expect(validateRuntimePins(aligned(), runtime)).toBe(0);
41
+ test('accepts caret ranges anchored at any patch or minor in the PATH Node major', () => {
42
+ for (const version of ['^24.0.3', '^24.13.0']) {
43
+ const pkg = aligned();
44
+ pkg.devDependencies['@types/node'] = version;
45
+ expect(validateRuntimePins(pkg, runtime)).toBe(0);
46
+ }
43
47
  });
44
48
 
45
49
  test('fails when @types/node tracks a different major than the PATH node', () => {
46
50
  const pkg = aligned();
47
- pkg.devDependencies['@types/node'] = '~26.1.1';
51
+ pkg.devDependencies['@types/node'] = '^26.1.1';
48
52
  expect(validateRuntimePins(pkg, runtime)).toBe(1);
49
53
  });
50
54
 
51
- test('fails the ~major.0.0 floor pin even when the major matches', () => {
52
- const pkg = aligned();
53
- pkg.devDependencies['@types/node'] = '~24.0.0';
54
- expect(validateRuntimePins(pkg, runtime)).toBe(1);
55
+ test('rejects exact and tilde @types/node pins even when the major matches', () => {
56
+ for (const version of ['24.13.0', '~24.13.0']) {
57
+ const pkg = aligned();
58
+ pkg.devDependencies['@types/node'] = version;
59
+ expect(validateRuntimePins(pkg, runtime)).toBe(1);
60
+ }
55
61
  });
56
62
 
57
63
  test('fails engines.node and packageManager drift against the PATH runtimes', () => {
@@ -66,8 +66,8 @@ export async function syncRootRuntimeVersions(root: string): Promise<void> {
66
66
  /**
67
67
  * Validate that package.json runtime pins agree with the LIVE PATH runtimes,
68
68
  * never a stored template. Offline by design: structural alignment only
69
- * (majors, exact bun pin, no `~major.0.0` floor) — the registry is not
70
- * consulted. Repair: `smoo monorepo init --runtime-only` inside the devenv shell.
69
+ * (Node types float within the runtime major; Bun types stay exact) — the registry
70
+ * is not consulted. Repair: `smoo monorepo init --runtime-only` inside the devenv shell.
71
71
  */
72
72
  export async function validateRootRuntimeVersions(root: string): Promise<number> {
73
73
  const packageJson = readJsonObject(join(root, 'package.json'));
@@ -100,15 +100,10 @@ export function validateRuntimePins(packageJson: PackageJson, runtime: RuntimeVe
100
100
  failures++;
101
101
  }
102
102
  const typesNode = packageJson.devDependencies?.['@types/node'] ?? null;
103
- const typesNodeParts = typesNode ? /^~(\d+)\.(\d+)\.(\d+)$/.exec(typesNode) : null;
103
+ const typesNodeParts = typesNode ? /^\^(\d+)\.(\d+)\.(\d+)$/.exec(typesNode) : null;
104
104
  if (!typesNodeParts || typesNodeParts[1] !== nodeMajor) {
105
105
  console.error(
106
- `package.json @types/node is ${typesNode ?? 'missing'} but the PATH node is v${runtime.node} — types track the runtime major (~${nodeMajor}.x.y, newest published minor); ${repair}`,
107
- );
108
- failures++;
109
- } else if (typesNodeParts[2] === '0' && typesNodeParts[3] === '0') {
110
- console.error(
111
- `package.json @types/node is pinned to the ~${nodeMajor}.0.0 floor — tilde locks the first patch line and strands the repo on early broken releases; ${repair} to repin to the newest published ${nodeMajor}.x`,
106
+ `package.json @types/node is ${typesNode ?? 'missing'} but the PATH node is v${runtime.node} — expected a caret range within the runtime major (^${nodeMajor}.x.y); ${repair}`,
112
107
  );
113
108
  failures++;
114
109
  }
@@ -144,15 +139,15 @@ export function runtimeTypesRangeForPublishedVersions(
144
139
 
145
140
  if (pinMode === 'major') {
146
141
  const runtimeMajor = parsedRuntimeVersion[0].toString();
147
- // Newest published types WITHIN the runtime major never the `~major.0.0`
148
- // floor: tilde locks the patch line, so the floor can strand consumers on a
149
- // broken early release (e.g. @types/node 24.0.x's URLPattern/DOM TS2403
150
- // conflict, fixed in 24.13) with no path to the repaired minors.
142
+ // Anchor the range at the newest published types in the runtime major. The caret
143
+ // admits later patches and minors without crossing into a different Node major.
151
144
  const latestInMajor = latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor));
152
- if (latestInMajor) {
153
- return `~${latestInMajor}`;
145
+ if (!latestInMajor) {
146
+ throw new Error(`Unable to find published ${packageName} versions for runtime major ${runtimeMajor}`);
154
147
  }
155
- } else if (versions.includes(runtimeVersion)) {
148
+ return `^${latestInMajor}`;
149
+ }
150
+ if (versions.includes(runtimeVersion)) {
156
151
  return runtimeVersion;
157
152
  }
158
153
 
@@ -162,7 +157,7 @@ export function runtimeTypesRangeForPublishedVersions(
162
157
  }
163
158
 
164
159
  console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
165
- return pinMode === 'major' ? `~${latest}` : latest;
160
+ return latest;
166
161
  }
167
162
 
168
163
  function latestVersion(versions: readonly string[]): string | null {
@@ -19,7 +19,7 @@ const registryVersions: Record<string, string[]> = {
19
19
  'eslint-stdout': ['1.1.1', '1.1.2'],
20
20
  nx: ['23.1.0'],
21
21
  prettier: ['3.6.1'],
22
- ttsc: ['0.19.3'],
22
+ ttsc: ['0.25.0'],
23
23
  typescript: ['6.0.3'],
24
24
  // alias package is not fetched from registry under this name when using fallback
25
25
  '@typescript/native': ['7.0.2'],
@@ -50,6 +50,9 @@ describe('tool configuration validation', () => {
50
50
  devDependencies: {
51
51
  '@smoothbricks/cli': 'workspace:*',
52
52
  },
53
+ patchedDependencies: {
54
+ 'ttsc@0.19.3': 'patches/ttsc@0.19.3.patch',
55
+ },
53
56
  });
54
57
  const devenvPath = join(root, 'tooling/direnv/devenv.nix');
55
58
  await mkdir(dirname(devenvPath), { recursive: true });
@@ -76,10 +79,10 @@ describe('tool configuration validation', () => {
76
79
  expect(rootPackage.devDependencies['@smoothbricks/nx-plugin']).toBe('workspace:*');
77
80
  expect(rootPackage.devDependencies['eslint-stdout']).toBe('workspace:*');
78
81
  expect(rootPackage.devDependencies.nx).toBe('23.1.0');
79
- expect(rootPackage.devDependencies.ttsc).toBe('0.19.3');
82
+ expect(rootPackage.devDependencies.ttsc).toBe('0.25.0');
80
83
  expect(rootPackage.devDependencies.typescript).toBe('^6.0.3');
81
84
  expect(rootPackage.devDependencies['@typescript/native']).toBe('npm:typescript@^7.0.2');
82
- expect(rootPackage.patchedDependencies['ttsc@0.19.3']).toBe('patches/ttsc@0.19.3.patch');
85
+ expect(rootPackage.patchedDependencies?.['ttsc@0.19.3']).toBeUndefined();
83
86
  expect(rootPackage.workspaces).toContain('tooling');
84
87
  expect(toolingPackage.name).toBe('@smoothbricks/tooling');
85
88
  expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe('workspace:*');
@@ -91,7 +94,7 @@ describe('tool configuration validation', () => {
91
94
  }
92
95
  });
93
96
 
94
- it('accepts newer dependency versions above the policy floor', async () => {
97
+ it('accepts newer dependencies after removing even an empty obsolete ttsc patch key', async () => {
95
98
  const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
96
99
  try {
97
100
  await writeJson(join(root, 'package.json'), {
@@ -108,12 +111,12 @@ describe('tool configuration validation', () => {
108
111
  'eslint-stdout': await currentEslintStdoutRange(),
109
112
  nx: '24.0.0',
110
113
  prettier: '^3.7.0',
111
- ttsc: '0.19.3',
114
+ ttsc: '0.25.0',
112
115
  typescript: '^6.0.0',
113
116
  '@typescript/native': 'npm:typescript@^7.0.2',
114
117
  },
115
118
  patchedDependencies: {
116
- 'ttsc@0.19.3': 'patches/ttsc@0.19.3.patch',
119
+ 'ttsc@0.19.3': '',
117
120
  },
118
121
  });
119
122
  await writeJson(join(root, 'tooling/package.json'), {
@@ -143,7 +146,11 @@ describe('tool configuration validation', () => {
143
146
  `,
144
147
  );
145
148
 
149
+ expect(await validateToolConfig(root)).toBe(1);
150
+ await applyToolConfigDefaults(root);
146
151
  expect(await validateToolConfig(root)).toBe(0);
152
+ const rootPackage = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
153
+ expect(rootPackage.patchedDependencies?.['ttsc@0.19.3']).toBeUndefined();
147
154
  } finally {
148
155
  await rm(root, { recursive: true, force: true });
149
156
  }
@@ -210,7 +217,7 @@ describe('tool configuration validation', () => {
210
217
  eslint: '^10.0.0',
211
218
  nx: '24.0.0',
212
219
  prettier: '^3.7.0',
213
- ttsc: '0.19.3',
220
+ ttsc: '0.25.0',
214
221
  typescript: '^6.0.0',
215
222
  },
216
223
  });
@@ -224,7 +231,7 @@ describe('tool configuration validation', () => {
224
231
  expect(rootPackage.devDependencies['eslint-stdout']).toBe(await currentEslintStdoutRange());
225
232
  expect(rootPackage.devDependencies['@smoothbricks/nx-plugin']).toBe(await currentNxPluginRange());
226
233
  expect(rootPackage.devDependencies['@typescript/native']).toBe('npm:typescript@^7.0.2');
227
- expect(rootPackage.patchedDependencies?.['ttsc@0.19.3']).toBe('patches/ttsc@0.19.3.patch');
234
+ expect(rootPackage.patchedDependencies?.['ttsc@0.19.3']).toBeUndefined();
228
235
  expect(toolingPackage.name).toBe('@fixture/tooling');
229
236
  expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe(await currentCliRange());
230
237
  } finally {
@@ -56,8 +56,7 @@ const rootDevDependencies: RequiredDependency[] = [
56
56
  },
57
57
  { name: 'nx', fallbackVersion: '23.1.0', minimumVersion: '23.1.0' },
58
58
  { name: 'prettier', fallbackVersion: '^3.6.1', minimumVersion: '3.6.0', prefix: '^' },
59
- // Exact pin: Bun patchedDependencies keys are version-locked (see patches/ttsc@0.19.3.patch).
60
- { name: 'ttsc', fallbackVersion: '0.19.3' },
59
+ { name: 'ttsc', fallbackVersion: '0.25.0' },
61
60
  // Nx and typescript-eslint still load the TypeScript JS API (6.x).
62
61
  // Compilation is exclusively delegated to ttsc by the Nx plugin targets.
63
62
  { name: 'typescript', fallbackVersion: '^6.0.3', minimumVersion: '6.0.0', prefix: '^' },
@@ -75,8 +74,7 @@ const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejan
75
74
  // never against a version template here.
76
75
  const nodePackagePattern = /(^|\s)nodejs(_\d+|_latest)?(\s|#|$)/m;
77
76
 
78
- const TTSC_PATCHED_DEPENDENCY_KEY = 'ttsc@0.19.3';
79
- const TTSC_PATCH_PATH = 'patches/ttsc@0.19.3.patch';
77
+ const obsoleteTtscPatchedDependencyKey = 'ttsc@0.19.3';
80
78
 
81
79
  export async function applyToolConfigDefaults(root: string): Promise<void> {
82
80
  const context = await readToolContext(root);
@@ -89,7 +87,7 @@ export async function validateToolConfig(root: string): Promise<number> {
89
87
  const context = await readToolContext(root);
90
88
  return (
91
89
  validateRootDevDependencies(context.policy, context.rootPackage) +
92
- validateTtscPatchedDependency(context.rootPackage) +
90
+ validateObsoleteTtscPatchRemoved(context.rootPackage) +
93
91
  validateToolingPackage(root, context.policy) +
94
92
  validateToolingWorkspace(context.rootPackage) +
95
93
  validateDevenvPackages(root)
@@ -113,7 +111,7 @@ export async function applyRootDevDependencyDefaults(root: string, context: Tool
113
111
  if (delete devDependencies['@smoothbricks/cli']) {
114
112
  changed = true;
115
113
  }
116
- changed = ensureTtscPatchedDependency(pkg) || changed;
114
+ changed = removeObsoleteTtscPatch(pkg) || changed;
117
115
  if (changed) {
118
116
  writeJsonObject(join(root, 'package.json'), pkg);
119
117
  console.log('updated package.json workspace tool dependencies');
@@ -129,7 +127,6 @@ async function applyRootPackageToolDefaults(root: string, context: ToolContext):
129
127
  }
130
128
  let dependencyChanged = false;
131
129
  let workspaceChanged = false;
132
- let patchChanged = false;
133
130
  const devDependencies = ensureDependencyMap(pkg, 'devDependencies');
134
131
  for (const dependency of rootDevDependencies) {
135
132
  const current = devDependencies[dependency.name];
@@ -142,8 +139,8 @@ async function applyRootPackageToolDefaults(root: string, context: ToolContext):
142
139
  dependencyChanged = true;
143
140
  }
144
141
  workspaceChanged = addWorkspacePattern(pkg, 'tooling');
145
- patchChanged = ensureTtscPatchedDependency(pkg);
146
- if (dependencyChanged || workspaceChanged || patchChanged) {
142
+ dependencyChanged = removeObsoleteTtscPatch(pkg) || dependencyChanged;
143
+ if (dependencyChanged || workspaceChanged) {
147
144
  writeJsonObject(join(root, 'package.json'), pkg);
148
145
  }
149
146
  console.log(
@@ -156,11 +153,6 @@ async function applyRootPackageToolDefaults(root: string, context: ToolContext):
156
153
  ? 'updated package.json tooling workspace'
157
154
  : 'unchanged package.json tooling workspace',
158
155
  );
159
- console.log(
160
- patchChanged
161
- ? 'updated package.json patchedDependencies for ttsc'
162
- : 'unchanged package.json patchedDependencies for ttsc',
163
- );
164
156
  }
165
157
 
166
158
  export function applyToolingPackageDefaults(root: string, policy: ToolPolicy): void {
@@ -252,22 +244,25 @@ export function validateRootDevDependencies(policy: ToolPolicy, rootPackage: Pac
252
244
  return failures;
253
245
  }
254
246
 
255
- function ensureTtscPatchedDependency(pkg: PackageJson): boolean {
256
- const patched = ensureDependencyMap(pkg, 'patchedDependencies');
257
- return setStringProperty(patched, TTSC_PATCHED_DEPENDENCY_KEY, TTSC_PATCH_PATH);
247
+ function removeObsoleteTtscPatch(pkg: PackageJson): boolean {
248
+ const patchedDependencies = pkg.patchedDependencies;
249
+ if (!patchedDependencies || !(obsoleteTtscPatchedDependencyKey in patchedDependencies)) {
250
+ return false;
251
+ }
252
+ delete patchedDependencies[obsoleteTtscPatchedDependencyKey];
253
+ if (Object.keys(patchedDependencies).length === 0) {
254
+ delete pkg.patchedDependencies;
255
+ }
256
+ return true;
258
257
  }
259
258
 
260
- function validateTtscPatchedDependency(rootPackage: PackageJson | null): number {
261
- if (!rootPackage) {
262
- return 0;
263
- }
264
- const actual = rootPackage.patchedDependencies?.[TTSC_PATCHED_DEPENDENCY_KEY];
265
- if (actual === TTSC_PATCH_PATH) {
259
+ function validateObsoleteTtscPatchRemoved(rootPackage: PackageJson | null): number {
260
+ const patchedDependencies = rootPackage?.patchedDependencies;
261
+ if (!patchedDependencies || !(obsoleteTtscPatchedDependencyKey in patchedDependencies)) {
266
262
  return 0;
267
263
  }
268
264
  console.error(
269
- `package.json patchedDependencies.${TTSC_PATCHED_DEPENDENCY_KEY} must be ${TTSC_PATCH_PATH}` +
270
- (typeof actual === 'string' ? `; found ${actual}` : ' (required for declaration-emit fix)'),
265
+ `package.json patchedDependencies.${obsoleteTtscPatchedDependencyKey} must be removed; ttsc 0.25.0 emits typia declarations without the obsolete patch`,
271
266
  );
272
267
  return 1;
273
268
  }