@dbx-tools/projen 0.9.54 → 0.9.60

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/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.54";
6
+ export const PACKAGE_VERSION = "0.9.60";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -29,6 +29,7 @@ export * as release from "./src/release.ts";
29
29
  export * as releaseContext from "./src/release-context.ts";
30
30
  export * as releaseOptions from "./src/release-options.ts";
31
31
  export * as scaffold from "./src/scaffold.ts";
32
+ export * as sharedCoreUsage from "./src/shared-core-usage.ts";
32
33
  export * as tags from "./src/tags.ts";
33
34
  export * as tsconfig from "./src/tsconfig.ts";
34
35
  export * as vscode from "./src/vscode.ts";
@@ -65,6 +66,8 @@ export type { ReleaseDocsOptions, DBXToolsReleaseOptions } from "./src/release.t
65
66
  export { RELEASE_VERSION } from "./src/release-context.ts";
66
67
  export { RELEASE_PUBLISH_TARGETS, RELEASE_INSTALL_MODES, ReleaseStepSelectionSchema } from "./src/release-options.ts";
67
68
  export type { ReleasePublishTarget, ReleaseInstallMode, ReleaseStepSelection, ReleaseSelectionOptions } from "./src/release-options.ts";
69
+ export { SHARED_CORE_BINDING_EXPORTS } from "./src/shared-core-usage.ts";
70
+ export type { SharedCoreUsageKind, SharedCoreExportUsage } from "./src/shared-core-usage.ts";
68
71
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
69
72
  export type { PackageTag } from "./src/tags.ts";
70
73
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.54",
34
- "@dbx-tools/path": "^0.9.54",
35
- "@dbx-tools/shared-core": "^0.9.54",
33
+ "@dbx-tools/core": "^0.9.60",
34
+ "@dbx-tools/path": "^0.9.60",
35
+ "@dbx-tools/shared-core": "^0.9.60",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "main": "index.ts",
56
56
  "license": "Apache-2.0",
57
- "version": "0.9.54",
57
+ "version": "0.9.60",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
package/src/project-js.ts CHANGED
@@ -275,7 +275,7 @@ export function projectReleaseBranch(project: DBXToolsJavaScriptProject): string
275
275
  */
276
276
  export function addPackageFiles(pkg: javascript.NodeProject, ...entries: string[]): void {
277
277
  const current = (pkg.package.manifest.files ?? []) as string[];
278
- pkg.package.addField("files", [...new Set([...current, ...entries])]);
278
+ pkg.package.addField("files", [...object.sequence(current, entries).distinct()]);
279
279
  }
280
280
 
281
281
  /**
@@ -859,16 +859,45 @@ class GeneratedSource extends Component {
859
859
  * are the child's business - so `bun run build` at the root type-checked nothing
860
860
  * and ran no package tests. Compilation groups ordinary package tsconfigs into
861
861
  * a few TypeScript processes and runs custom package lifecycles alongside them.
862
- * Tests still use Bun's filtered workspace fan-out. Both read the current
863
- * workspace list from `package.json`, so a newly added member needs no re-synth.
864
- *
865
- * `*` matches every workspace MEMBER and never the root itself, so the test
866
- * task delegating to it cannot recurse. Members declared outside the scanned
867
- * package roots (`extraWorkspaceMembers`) are covered by both tasks.
862
+ * Tests use a small number of root runner groups, skip packages without tests,
863
+ * and expose focused, changed, unit, integration, and full tiers. The test task
864
+ * reads the current workspace list from `package.json`, so a newly added member
865
+ * needs no re-synth.
868
866
  */
869
867
  class WorkspaceValidationTasks extends Component {
870
868
  private configured = false;
871
869
 
870
+ public constructor(project: DBXToolsNodeProject | DBXToolsTypeScriptProject) {
871
+ super(project);
872
+ if (project.parent) return;
873
+ project.addTask("workspace:graph", {
874
+ description: "Write the workspace dependency and reverse-dependency graph",
875
+ execArgs: taskCommand("test-workspace.ts", "graph"),
876
+ });
877
+ project.addTask("test:focused", {
878
+ description: "Run tests for explicit workspace packages or paths",
879
+ execArgs: taskCommand("test-workspace.ts", "focused"),
880
+ receiveArgs: true,
881
+ });
882
+ project.addTask("test:changed", {
883
+ description: "Run tests for changed packages and their dependents",
884
+ execArgs: taskCommand("test-workspace.ts", "changed"),
885
+ receiveArgs: true,
886
+ });
887
+ project.addTask("test:unit", {
888
+ description: "Run unit tests in grouped root runners",
889
+ execArgs: taskCommand("test-workspace.ts", "unit"),
890
+ });
891
+ project.addTask("test:integration", {
892
+ description: "Run integration and packed-consumer tests",
893
+ execArgs: taskCommand("test-workspace.ts", "integration"),
894
+ });
895
+ project.addTask("test:all", {
896
+ description: "Run all JavaScript, TypeScript, and Python tests",
897
+ execArgs: taskCommand("test-workspace.ts", "all"),
898
+ });
899
+ }
900
+
872
901
  public override preSynthesize(): void {
873
902
  if (this.configured) return;
874
903
  this.configured = true;
@@ -877,7 +906,7 @@ class WorkspaceValidationTasks extends Component {
877
906
  return;
878
907
  }
879
908
  project.compileTask.execArgs(taskCommand("compile-workspace.ts"));
880
- project.testTask.exec("bun run --filter '*' test");
909
+ project.testTask.spawn(project.tasks.tryFind("test:all")!);
881
910
  }
882
911
  }
883
912
 
@@ -1253,7 +1282,7 @@ function initProject(
1253
1282
  // agnostic floor is set in the child's constructor; per-tag deps/tsconfig come from
1254
1283
  // the PACKAGE_TAG_MIXINS applied across the subtree below.
1255
1284
  for (const p of scanPackages(rootAbs, roots)) {
1256
- const tags = [...new Set([...p.tagCandidates, ...resolveTags(p, tagPaths)])];
1285
+ const tags = [...object.sequence(p.tagCandidates, resolveTags(p, tagPaths)).distinct()];
1257
1286
  const found = existing.get(p.memberPath);
1258
1287
  if (found) {
1259
1288
  found.dbxToolsConfig.tags.push(...tags);
@@ -1272,7 +1301,7 @@ function initProject(
1272
1301
  }
1273
1302
 
1274
1303
  // The root project may itself carry tags (via a `""`/`"."` tag-path key).
1275
- const rootTags = [...new Set([...(tagPaths[""] ?? []), ...(tagPaths["."] ?? [])])];
1304
+ const rootTags = [...object.sequence(tagPaths[""], tagPaths["."]).distinct()];
1276
1305
  if (rootTags.length) project.dbxToolsConfig.tags.push(...rootTags);
1277
1306
 
1278
1307
  // Apply per-tag mixins across the whole subtree now that every child exists
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Runtime-export usage for `@dbx-tools/shared-core`.
3
+ *
4
+ * Classifies each live export as production, test, package-internal, generated
5
+ * binding, or unused. The report is informational; architecture tests fail only
6
+ * unused exports and duplicated owned helpers.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ import { find } from "@dbx-tools/path";
11
+ import * as core from "@dbx-tools/shared-core";
12
+ import { PACKAGE_IDENTIFIER, object } from "@dbx-tools/shared-core";
13
+ import { PYTHON_GENERATED_PACKAGE } from "./generated.ts";
14
+ import { readPackageManifest, recordedPackages, repoRoot } from "./packages.ts";
15
+
16
+ /** How one runtime export is referenced in this repository. */
17
+ export type SharedCoreUsageKind = "production" | "test" | "internal" | "binding" | "unused";
18
+
19
+ /** One classified shared-core runtime export. */
20
+ export interface SharedCoreExportUsage {
21
+ readonly id: string;
22
+ readonly namespace: string;
23
+ readonly name: string;
24
+ readonly kind: SharedCoreUsageKind;
25
+ readonly files: readonly string[];
26
+ }
27
+
28
+ /** Binding-safe wrappers called from generated Python even without TS imports. */
29
+ export const SHARED_CORE_BINDING_EXPORTS = [
30
+ "bindings.logActiveLevel",
31
+ "bindings.logLevelEnabled",
32
+ ] as const;
33
+
34
+ const SOURCE_PATTERN = "**/*.{ts,tsx,js,mjs}";
35
+ const NAMESPACE_EXPORT = /^export \* as (\w+) from "\.\/src\/([^"]+)\.ts";$/gm;
36
+ const LOCAL_OWNED_HELPER =
37
+ /(?:export\s+)?(?:async\s+)?function(?:\s*\*)?\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*\(|(?:export\s+)?const\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*=/g;
38
+
39
+ /** Owners of the shared helpers; other files must import them. */
40
+ const OWNED_HELPER_FILES = new Set([
41
+ "packages/js/shared/core/src/object.ts",
42
+ "packages/js/shared/core/src/async-utils.ts",
43
+ "packages/js/shared/core/src/pattern.ts",
44
+ "packages/js/shared/core/src/string-utils.ts",
45
+ "docs/scripts/repository-docs.mjs",
46
+ ]);
47
+
48
+ export function sharedCorePackageDir(root: string = repoRoot): string {
49
+ for (const pkg of recordedPackages(root)) {
50
+ if (readPackageManifest(pkg.dir)?.name === PACKAGE_IDENTIFIER) return pkg.dir;
51
+ }
52
+ throw new Error(`workspace package ${PACKAGE_IDENTIFIER} is missing`);
53
+ }
54
+
55
+ function namespaceMap(coreDir: string): Readonly<Record<string, string>> {
56
+ const barrel = readFileSync(join(coreDir, "index.ts"), "utf8");
57
+ return Object.fromEntries(
58
+ [...barrel.matchAll(NAMESPACE_EXPORT)].map((match) => [match[1]!, match[2]!]),
59
+ );
60
+ }
61
+
62
+ function sourceFiles(root: string, includeGenerated: boolean): string[] {
63
+ const ignore = [
64
+ "**/node_modules/**",
65
+ "**/lib/**",
66
+ "**/.docs-build/**",
67
+ // Bundled PythonMonkey runtimes inline shared-core; they are not local copies.
68
+ "**/node_runtime/runtime.js",
69
+ "**/_runtime.js",
70
+ ];
71
+ if (!includeGenerated) ignore.push(`**/${PYTHON_GENERATED_PACKAGE}/**`);
72
+ return [...find.findFiles(SOURCE_PATTERN, { cwd: root, ignore })].sort();
73
+ }
74
+
75
+ function classifyFile(
76
+ relativePath: string,
77
+ coreDirRel: string,
78
+ ): Exclude<SharedCoreUsageKind, "unused"> {
79
+ if (relativePath.includes(`/${PYTHON_GENERATED_PACKAGE}/`)) return "binding";
80
+ if (relativePath.startsWith(`${coreDirRel}/src/`)) return "internal";
81
+ if (/(?:^|\/)(?:test|tests)\//.test(relativePath) || relativePath.includes(".test.")) {
82
+ return "test";
83
+ }
84
+ return "production";
85
+ }
86
+
87
+ function preferKind(
88
+ current: SharedCoreUsageKind,
89
+ next: Exclude<SharedCoreUsageKind, "unused">,
90
+ ): SharedCoreUsageKind {
91
+ const rank: Record<SharedCoreUsageKind, number> = {
92
+ production: 0,
93
+ binding: 1,
94
+ internal: 2,
95
+ test: 3,
96
+ unused: 4,
97
+ };
98
+ return rank[next] < rank[current] ? next : current;
99
+ }
100
+
101
+ function mentionsExport(
102
+ source: string,
103
+ namespace: string,
104
+ name: string,
105
+ moduleFile: string,
106
+ ): boolean {
107
+ if (source.includes(`${namespace}.${name}`)) return true;
108
+ const imported =
109
+ source.includes(`from "@dbx-tools/shared-core/${moduleFile}"`) ||
110
+ source.includes(`from "./${moduleFile}.ts"`) ||
111
+ source.includes(`from "../src/${moduleFile}.ts"`);
112
+ if (!imported) return false;
113
+ return new RegExp(`\\b${name}\\b`).test(source);
114
+ }
115
+
116
+ /** Classify every namespace runtime export against repository callers. */
117
+ export function sharedCoreExportUsage(root: string = repoRoot): SharedCoreExportUsage[] {
118
+ const coreDir = sharedCorePackageDir(root);
119
+ const coreDirRel = relative(root, coreDir);
120
+ const namespaces = namespaceMap(coreDir);
121
+ const files = sourceFiles(root, true).map((file) => ({
122
+ path: relative(root, file),
123
+ text: readFileSync(file, "utf8"),
124
+ }));
125
+ const usage: SharedCoreExportUsage[] = [];
126
+ for (const [namespace, moduleFile] of Object.entries(namespaces)) {
127
+ const exported = core[namespace as keyof typeof core];
128
+ if (!exported || typeof exported !== "object") continue;
129
+ for (const name of Object.keys(exported).sort()) {
130
+ if (name === "__esModule" || name === "default") continue;
131
+ const id = `${namespace}.${name}`;
132
+ const defining = `${coreDirRel}/src/${moduleFile}.ts`;
133
+ const matches = files
134
+ .filter(({ path, text }) => {
135
+ if (path === defining) {
136
+ return (text.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0) > 1;
137
+ }
138
+ return mentionsExport(text, namespace, name, moduleFile);
139
+ })
140
+ .map(({ path }) => path);
141
+ let kind: SharedCoreUsageKind = "unused";
142
+ if (
143
+ SHARED_CORE_BINDING_EXPORTS.includes(id as (typeof SHARED_CORE_BINDING_EXPORTS)[number])
144
+ ) {
145
+ kind = "binding";
146
+ }
147
+ for (const file of matches) kind = preferKind(kind, classifyFile(file, coreDirRel));
148
+ usage.push({ id, namespace, name, kind, files: matches });
149
+ }
150
+ }
151
+ return usage.sort((left, right) => left.id.localeCompare(right.id));
152
+ }
153
+
154
+ /** Local copies of helpers owned by shared-core. */
155
+ export function duplicatedOwnedHelpers(root: string = repoRoot): string[] {
156
+ const duplicates: string[] = [];
157
+ for (const file of sourceFiles(root, false)) {
158
+ const relativePath = relative(root, file);
159
+ if (OWNED_HELPER_FILES.has(relativePath)) continue;
160
+ if (relativePath.startsWith("packages/js/shared/core/test/")) continue;
161
+ const text = readFileSync(file, "utf8");
162
+ LOCAL_OWNED_HELPER.lastIndex = 0;
163
+ if (LOCAL_OWNED_HELPER.test(text)) duplicates.push(relativePath);
164
+ }
165
+ return duplicates.sort();
166
+ }
167
+
168
+ /** Render a compact usage table for `shared-core:usage`. */
169
+ export function formatSharedCoreUsage(usage: readonly SharedCoreExportUsage[]): string {
170
+ const counts = object.sequence(usage).group({
171
+ production: (row) => row.kind === "production",
172
+ binding: (row) => row.kind === "binding",
173
+ internal: (row) => row.kind === "internal",
174
+ test: (row) => row.kind === "test",
175
+ unused: (row) => row.kind === "unused",
176
+ });
177
+ const lines = [
178
+ `shared-core runtime exports: ${usage.length}`,
179
+ ` production ${counts.production.length}`,
180
+ ` binding ${counts.binding.length}`,
181
+ ` internal ${counts.internal.length}`,
182
+ ` test ${counts.test.length}`,
183
+ ` unused ${counts.unused.length}`,
184
+ ];
185
+ if (counts.unused.length > 0) {
186
+ lines.push("unused:");
187
+ for (const row of counts.unused) lines.push(` ${row.id}`);
188
+ }
189
+ return `${lines.join("\n")}\n`;
190
+ }
package/src/vscode.ts CHANGED
@@ -17,12 +17,28 @@ export function configureVsCode(scope: javascript.NodeProject): void {
17
17
  "javascript.preferences.importModuleSpecifier": "non-relative",
18
18
  "editor.formatOnSave": true,
19
19
  "editor.defaultFormatter": "esbenp.prettier-vscode",
20
+ "editor.codeActionsOnSave": {
21
+ "source.fixAll.eslint": "explicit",
22
+ },
23
+ "[python]": {
24
+ "editor.defaultFormatter": "charliermarsh.ruff",
25
+ "editor.formatOnSave": true,
26
+ "editor.codeActionsOnSave": {
27
+ "source.fixAll.ruff": "explicit",
28
+ },
29
+ },
30
+ "notebook.formatOnSave.enabled": true,
31
+ "notebook.defaultFormatter": "charliermarsh.ruff",
20
32
  "files.watcherExclude": {
21
33
  "**/node_modules/**": true,
22
34
  "**/dist/**": true,
23
35
  },
24
36
  });
25
- editor.extensions.addRecommendations("esbenp.prettier-vscode");
37
+ editor.extensions.addRecommendations(
38
+ "esbenp.prettier-vscode",
39
+ "dbaeumer.vscode-eslint",
40
+ "charliermarsh.ruff",
41
+ );
26
42
 
27
43
  new JsonFile(scope, ".vscode/tasks.json", {
28
44
  marker: false,
package/tasks/publish.ts CHANGED
@@ -46,7 +46,7 @@
46
46
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
47
47
  import { tmpdir } from "node:os";
48
48
  import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
49
- import { asyncUtils, log } from "@dbx-tools/shared-core";
49
+ import { asyncUtils, log, object } from "@dbx-tools/shared-core";
50
50
  import {
51
51
  applyPublishConfig,
52
52
  npmReleaseMatches,
@@ -169,7 +169,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
169
169
  `${pkg.name ?? dirname(dir)} has invalid publishConfig.access ${String(access)}`,
170
170
  );
171
171
  }
172
- const compiledTargets = [...new Set(compiledPublishTargets(pkg.publishConfig))];
172
+ const compiledTargets = [
173
+ ...object.sequence(compiledPublishTargets(pkg.publishConfig)).distinct(),
174
+ ];
173
175
  publishable.push({
174
176
  dir,
175
177
  name: pkg.name ?? dirname(dir),
@@ -2,7 +2,7 @@
2
2
  /** Keep every configured Python Node bridge current with one repository watcher. */
3
3
  import { dirname, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { log } from "@dbx-tools/shared-core";
5
+ import { log, object } from "@dbx-tools/shared-core";
6
6
  import { runTaskCommand } from "../src/_task-command.ts";
7
7
  import { repoRoot } from "../src/packages.ts";
8
8
  import {
@@ -41,7 +41,7 @@ function watchPaths(configs: readonly ResolvedPythonNodeBindings[]): string[] {
41
41
  const explicitInputs = configs
42
42
  .flatMap(pythonNodeBindingWatchInputs)
43
43
  .filter((input) => !covered.some((root) => containsPath(root, input)));
44
- return [...new Set([...covered, ...explicitInputs])].sort();
44
+ return [...object.sequence(covered, explicitInputs).distinct()].sort();
45
45
  }
46
46
 
47
47
  function selection(
@@ -463,7 +463,9 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
463
463
  "",
464
464
  ...(usesCallable ? ["from collections.abc import Callable"] : []),
465
465
  "from dataclasses import dataclass, field",
466
- "from typing import Any, NotRequired, Protocol, TypedDict",
466
+ "from typing import Any, Protocol, TypedDict",
467
+ "",
468
+ "from typing_extensions import NotRequired",
467
469
  "",
468
470
  "from .._runtime import MISSING as _MISSING",
469
471
  "from .._runtime import invoke_positioned as _invoke_positioned",
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { resolve } from "node:path";
5
- import { net } from "@dbx-tools/shared-core";
5
+ import { net, stringUtils } from "@dbx-tools/shared-core";
6
6
  import { parse } from "smol-toml";
7
7
  import { captureTaskCommand } from "../src/_task-command.ts";
8
8
 
@@ -120,12 +120,6 @@ export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
120
120
  };
121
121
  }
122
122
 
123
- /** Split a whitespace-separated index list (the pip/uv env-var form). */
124
- function splitIndexList(value: string | undefined): string[] {
125
- const trimmed = value?.trim();
126
- return trimmed ? trimmed.split(/\s+/) : [];
127
- }
128
-
129
123
  /** Read a `pip config get <key>`, treating pip's literal "undefined" as unset. */
130
124
  function pipConfig(key: string): string | undefined {
131
125
  const out = captureTaskCommand(process.cwd(), "python", ["-m", "pip", "config", "get", key]);
@@ -153,8 +147,8 @@ export function activePythonIndexes(): string[] {
153
147
  // uv: env vars first (primary, then extras), then uv.toml's index blocks.
154
148
  add(process.env.UV_DEFAULT_INDEX);
155
149
  add(process.env.UV_INDEX_URL);
156
- for (const url of splitIndexList(process.env.UV_INDEX)) add(url);
157
- for (const url of splitIndexList(process.env.UV_EXTRA_INDEX_URL)) add(url);
150
+ for (const url of stringUtils.parseList(process.env.UV_INDEX)) add(url);
151
+ for (const url of stringUtils.parseList(process.env.UV_EXTRA_INDEX_URL)) add(url);
158
152
  const uvConfig = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml");
159
153
  if (existsSync(uvConfig)) {
160
154
  for (const url of parseUvIndexes(readFileSync(uvConfig, "utf8"))) add(url);
@@ -162,9 +156,9 @@ export function activePythonIndexes(): string[] {
162
156
 
163
157
  // pip: env vars (primary + extras), then `pip config` (index-url + extra-index-url).
164
158
  add(process.env.PIP_INDEX_URL);
165
- for (const url of splitIndexList(process.env.PIP_EXTRA_INDEX_URL)) add(url);
159
+ for (const url of stringUtils.parseList(process.env.PIP_EXTRA_INDEX_URL)) add(url);
166
160
  add(pipConfig("global.index-url"));
167
- for (const url of splitIndexList(pipConfig("global.extra-index-url"))) add(url);
161
+ for (const url of stringUtils.parseList(pipConfig("global.extra-index-url"))) add(url);
168
162
 
169
163
  return out;
170
164
  }
package/tasks/release.ts CHANGED
@@ -161,6 +161,7 @@ export async function runRelease(
161
161
  );
162
162
  }
163
163
 
164
+ runTaskCommand(root, "git", ["push", remote, `HEAD:${branch}`]);
164
165
  runTaskCommand(root, "git", [
165
166
  "tag",
166
167
  "--annotate",
@@ -169,7 +170,7 @@ export async function runRelease(
169
170
  releaseTagAnnotation(tag, selection),
170
171
  ]);
171
172
  try {
172
- runTaskCommand(root, "git", ["push", "--atomic", remote, `HEAD:${branch}`, `refs/tags/${tag}`]);
173
+ runTaskCommand(root, "git", ["push", remote, `refs/tags/${tag}`]);
173
174
  } catch (error) {
174
175
  runTaskCommand(root, "git", ["tag", "--delete", tag]);
175
176
  throw error;
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Print shared-core runtime-export usage by caller kind. */
3
+ import { formatSharedCoreUsage, sharedCoreExportUsage } from "../src/shared-core-usage.ts";
4
+
5
+ process.stdout.write(formatSharedCoreUsage(sharedCoreExportUsage()));
@@ -0,0 +1,253 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve, sep } from "node:path";
3
+ import * as exec from "@dbx-tools/core/exec";
4
+ import { log, object } from "@dbx-tools/shared-core";
5
+
6
+ const logger = log.logger("projen:test");
7
+
8
+ interface PackageManifest {
9
+ readonly name?: string;
10
+ readonly workspaces?: readonly string[];
11
+ readonly dependencies?: Readonly<Record<string, string>>;
12
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
13
+ readonly peerDependencies?: Readonly<Record<string, string>>;
14
+ readonly devDependencies?: Readonly<Record<string, string>>;
15
+ }
16
+
17
+ export interface WorkspaceGraphPackage {
18
+ readonly name: string;
19
+ readonly path: string;
20
+ readonly dependencies: readonly string[];
21
+ readonly reverseDependencies: readonly string[];
22
+ }
23
+
24
+ export interface WorkspaceGraph {
25
+ readonly packages: readonly WorkspaceGraphPackage[];
26
+ }
27
+
28
+ type TestMode = "focused" | "changed" | "unit" | "integration" | "all" | "graph";
29
+
30
+ const PROJEN_INTEGRATION_TEST =
31
+ /(?:local-publish|npm-release|packed-consumer|project-py|publish|python-sync|release|root-install|sdk-boundary).*\.test\.ts$/;
32
+
33
+ function readJson<T>(path: string): T {
34
+ return JSON.parse(readFileSync(path, "utf8")) as T;
35
+ }
36
+
37
+ function toPosix(path: string): string {
38
+ return path.split(sep).join("/");
39
+ }
40
+
41
+ export function workspaceGraph(root: string): WorkspaceGraph {
42
+ const workspace = readJson<PackageManifest>(join(root, "package.json"));
43
+ const packages = new Map<string, { path: string; manifest: PackageManifest }>();
44
+ for (const member of workspace.workspaces ?? []) {
45
+ const manifest = readJson<PackageManifest>(join(root, member, "package.json"));
46
+ if (manifest.name) packages.set(manifest.name, { path: toPosix(member), manifest });
47
+ }
48
+ const reverse = new Map<string, Set<string>>();
49
+ const rows = [...packages].map(([name, pkg]) => {
50
+ const dependencies = new Set<string>();
51
+ for (const field of [
52
+ "dependencies",
53
+ "optionalDependencies",
54
+ "peerDependencies",
55
+ "devDependencies",
56
+ ] as const) {
57
+ for (const dependency of Object.keys(pkg.manifest[field] ?? {})) {
58
+ if (!packages.has(dependency)) continue;
59
+ dependencies.add(dependency);
60
+ const dependents = reverse.get(dependency) ?? new Set<string>();
61
+ dependents.add(name);
62
+ reverse.set(dependency, dependents);
63
+ }
64
+ }
65
+ return { name, path: pkg.path, dependencies: [...dependencies].sort() };
66
+ });
67
+ return {
68
+ packages: rows
69
+ .map((pkg) => ({
70
+ ...pkg,
71
+ reverseDependencies: [...(reverse.get(pkg.name) ?? [])].sort(),
72
+ }))
73
+ .sort((left, right) => left.name.localeCompare(right.name)),
74
+ };
75
+ }
76
+
77
+ function writeGraph(root: string, graph: WorkspaceGraph): void {
78
+ const path = join(root, ".projen", "workspace-graph.json");
79
+ writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`);
80
+ logger.info(`wrote ${relative(root, path)}`);
81
+ }
82
+
83
+ function testFiles(directory: string): string[] {
84
+ if (!existsSync(directory)) return [];
85
+ const files: string[] = [];
86
+ const visit = (current: string): void => {
87
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
88
+ const path = join(current, entry.name);
89
+ if (entry.isDirectory()) visit(path);
90
+ else if (/\.(?:test|spec)\.tsx?$/.test(entry.name)) files.push(path);
91
+ }
92
+ };
93
+ visit(directory);
94
+ return files.sort();
95
+ }
96
+
97
+ function integrationTest(path: string): boolean {
98
+ return (
99
+ path.includes(".integration.test.") ||
100
+ (toPosix(path).includes("/projen/test/") && PROJEN_INTEGRATION_TEST.test(basename(path)))
101
+ );
102
+ }
103
+
104
+ function packageForPath(graph: WorkspaceGraph, path: string): WorkspaceGraphPackage | undefined {
105
+ const normalized = toPosix(path);
106
+ return [...graph.packages]
107
+ .sort((left, right) => right.path.length - left.path.length)
108
+ .find((pkg) => normalized === pkg.path || normalized.startsWith(`${pkg.path}/`));
109
+ }
110
+
111
+ function affectedPackages(graph: WorkspaceGraph, changed: readonly string[]): Set<string> {
112
+ const byName = new Map(graph.packages.map((pkg) => [pkg.name, pkg]));
113
+ const affected = new Set<string>();
114
+ const pending: string[] = [];
115
+ for (const path of changed) {
116
+ const pkg = packageForPath(graph, path);
117
+ if (!pkg) return new Set(graph.packages.map((entry) => entry.name));
118
+ if (!affected.has(pkg.name)) pending.push(pkg.name);
119
+ }
120
+ while (pending.length > 0) {
121
+ const name = pending.pop()!;
122
+ if (affected.has(name)) continue;
123
+ affected.add(name);
124
+ pending.push(...(byName.get(name)?.reverseDependencies ?? []));
125
+ }
126
+ return affected;
127
+ }
128
+
129
+ function changedFiles(root: string, base?: string): string[] {
130
+ const candidates = base ? [base] : ["origin/main", "main", "HEAD~1"];
131
+ let committed: string[] = [];
132
+ for (const candidate of candidates) {
133
+ const result = exec.spawnSync("git", ["diff", "--name-only", `${candidate}...HEAD`], {
134
+ cwd: root,
135
+ stdin: "ignore",
136
+ stdout: "capture",
137
+ stderr: "ignore",
138
+ });
139
+ if (result.exitCode === 0) {
140
+ committed = result.stdoutLines.filter(Boolean);
141
+ break;
142
+ }
143
+ }
144
+ const working = exec.spawnSync("git", ["diff", "--name-only", "HEAD"], {
145
+ cwd: root,
146
+ stdin: "ignore",
147
+ stdout: "capture",
148
+ stderr: "ignore",
149
+ check: true,
150
+ }).stdoutLines;
151
+ return [...object.sequence(committed, working).filter(Boolean).distinct()].sort();
152
+ }
153
+
154
+ function selectedTests(
155
+ root: string,
156
+ graph: WorkspaceGraph,
157
+ mode: TestMode,
158
+ args: readonly string[],
159
+ ): string[] {
160
+ const all = [
161
+ ...graph.packages.flatMap((pkg) => testFiles(join(root, pkg.path, "test"))),
162
+ ...testFiles(join(root, "docs/scripts")),
163
+ ];
164
+ if (mode === "unit") return all.filter((path) => !integrationTest(path));
165
+ if (mode === "integration") return all.filter(integrationTest);
166
+ if (mode === "all") return all;
167
+
168
+ let names: Set<string>;
169
+ if (mode === "changed") {
170
+ const baseIndex = args.indexOf("--base");
171
+ const changed = changedFiles(root, baseIndex >= 0 ? args[baseIndex + 1] : undefined);
172
+ if (changed.length === 0) return [];
173
+ names = affectedPackages(graph, changed);
174
+ } else {
175
+ if (args.length === 0) throw new Error("test:focused requires a package name or path");
176
+ names = new Set<string>();
177
+ for (const value of args) {
178
+ const direct = graph.packages.find((pkg) => pkg.name === value);
179
+ const byPath = direct ?? packageForPath(graph, toPosix(relative(root, resolve(root, value))));
180
+ if (!byPath) throw new Error(`Unknown workspace package or path: ${value}`);
181
+ names.add(byPath.name);
182
+ }
183
+ }
184
+ const paths = new Set(graph.packages.filter((pkg) => names.has(pkg.name)).map((pkg) => pkg.path));
185
+ return all.filter((path) => {
186
+ const relativePath = toPosix(relative(root, path));
187
+ return [...paths].some((pkg) => relativePath.startsWith(`${pkg}/`));
188
+ });
189
+ }
190
+
191
+ function runnerGroup(root: string, path: string): string {
192
+ const relativePath = toPosix(relative(root, path));
193
+ if (relativePath.startsWith("packages/js/shared/")) return "browser-shared";
194
+ if (
195
+ relativePath.startsWith("packages/js/ui/") ||
196
+ relativePath.startsWith("packages/example/app/")
197
+ ) {
198
+ return "react-ui";
199
+ }
200
+ if (relativePath.startsWith("projen/")) return "projen";
201
+ if (relativePath.startsWith("docs/scripts/")) return "docs";
202
+ if (relativePath.startsWith("packages/example/")) return "examples";
203
+ return "node";
204
+ }
205
+
206
+ async function runTests(root: string, files: readonly string[]): Promise<void> {
207
+ if (files.length === 0) {
208
+ logger.info("no tests selected");
209
+ return;
210
+ }
211
+ const groups = new Map<string, string[]>();
212
+ for (const file of files) {
213
+ const name = runnerGroup(root, file);
214
+ const paths = groups.get(name) ?? [];
215
+ paths.push(relative(root, file));
216
+ groups.set(name, paths);
217
+ }
218
+ const started = performance.now();
219
+ const jobs = [...groups].map(async ([name, paths]) => {
220
+ const groupStarted = performance.now();
221
+ const result = await exec.spawn("bun", ["test", ...paths], {
222
+ cwd: root,
223
+ stdin: "ignore",
224
+ stdout: "inherit",
225
+ stderr: "inherit",
226
+ });
227
+ const seconds = ((performance.now() - groupStarted) / 1000).toFixed(2);
228
+ logger.info(`${name}: ${paths.length} files in ${seconds}s`);
229
+ return { name, result };
230
+ });
231
+ const results = await Promise.all(jobs);
232
+ logger.info(
233
+ `selected tests: ${files.length} files in ${((performance.now() - started) / 1000).toFixed(2)}s`,
234
+ );
235
+ const failed = results.filter(({ result }) => result.exitCode !== 0);
236
+ if (failed.length > 0) {
237
+ throw new Error(`Test groups failed: ${failed.map(({ name }) => name).join(", ")}`);
238
+ }
239
+ }
240
+
241
+ export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
242
+ const root = process.cwd();
243
+ const mode = (argv[0] ?? "all") as TestMode;
244
+ if (!["focused", "changed", "unit", "integration", "all", "graph"].includes(mode)) {
245
+ throw new Error(`Unknown test mode: ${mode}`);
246
+ }
247
+ const graph = workspaceGraph(root);
248
+ writeGraph(root, graph);
249
+ if (mode === "graph") return;
250
+ await runTests(root, selectedTests(root, graph, mode, argv.slice(1)));
251
+ }
252
+
253
+ if (import.meta.main) await main();
@@ -0,0 +1,22 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const entries = {
4
+ auth: "packages/js/ui/appkit/src/auth/react/index.ts",
5
+ email: "packages/js/ui/appkit/src/email/react/index.ts",
6
+ search: "packages/js/ui/appkit/src/search/react/index.ts",
7
+ } as const;
8
+
9
+ for (const [name, entry] of Object.entries(entries)) {
10
+ const result = await Bun.build({
11
+ entrypoints: [resolve(entry)],
12
+ target: "browser",
13
+ minify: true,
14
+ sourcemap: "none",
15
+ external: ["react", "react-dom", "@databricks/*", "@dbx-tools/shared-*", "lucide-react"],
16
+ });
17
+ if (!result.success) {
18
+ throw new AggregateError(result.logs, `Could not build the ${name} UI subpath`);
19
+ }
20
+ const bytes = result.outputs.reduce((total, output) => total + output.size, 0);
21
+ console.log(`${name}: ${bytes} bytes`);
22
+ }