@dbx-tools/projen 0.6.58 → 0.6.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/README.md CHANGED
@@ -124,6 +124,35 @@ package is attempted even if one fails; the failures are re-thrown together as a
124
124
  `AggregateError` naming each package, rather than the first one abandoning the
125
125
  rest of the sweep.
126
126
 
127
+ ### Generate Node And Python From Rosetta Sources
128
+
129
+ The experimental `rs-packages` convention keeps TypeScript as the canonical
130
+ Node implementation while colocating an explicit Python implementation in the
131
+ same file:
132
+
133
+ ```ts
134
+ export function capitalize(value: string): string {
135
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value;
136
+ }
137
+
138
+ /* @rs-python
139
+ def capitalize(value: str) -> str:
140
+ return value[:1].upper() + value[1:] if value else value
141
+ @rs-end */
142
+ ```
143
+
144
+ By default, `rs-packages/shared/core/src/string.ts` generates:
145
+
146
+ - `packages/shared/core/src/string.ts`
147
+ - `python-packages/shared-core/src/shared_core/string.py`
148
+
149
+ Use `// @rs-node <path>` or `// @rs-python-path <path>` only when the inferred
150
+ paths are unsuitable. Every Rosetta source must live below a package's `src/`
151
+ folder and include at least one Python block; generation fails rather than
152
+ silently emitting an empty Python module. `DBXToolsNodeProject` enables the
153
+ `rs-packages` root by default, accepts `rsPackageRoots` for custom roots, and
154
+ accepts `rsPackageRoots: false` to disable the experiment.
155
+
127
156
  ## Generate OpenAPI Clients
128
157
 
129
158
  ```ts
@@ -188,6 +217,7 @@ file contract as the CLI.
188
217
  - `pnpmWorkspace` - generated pnpm workspace file and catalog model.
189
218
  - `barrels` / `moduleExports` - public entrypoint generation.
190
219
  - `codegen` - `.d.ts` to zod schema generation.
220
+ - `rsPackages` - annotated TypeScript source to Node/Python generation.
191
221
  - `openapi` - tsoa/OpenAPI package generation.
192
222
  - `bunApp` / `tsconfig` / `vscode` - generated support files/components.
193
223
  - `generated` / `clean` / `watch` / `scaffold` - read-only file stamping,
@@ -196,8 +226,9 @@ file contract as the CLI.
196
226
  - `engineRoot` - engine package root resolution for bootstrapped repos.
197
227
 
198
228
  The engine registers its commands as projen tasks on the workspace root, so run
199
- them with `bun run <task>` - `sync` (add `--watch`), `barrels`, `openapi`, and
200
- `clean`. [`@dbx-tools/cli`](../packages/cli/dbx-tools) is only needed to
229
+ them with `bun run <task>` - `sync` (add `--watch`), `barrels`, `openapi`,
230
+ `rs-packages` (also supports `--watch`), and `clean`.
231
+ [`@dbx-tools/cli`](../packages/cli/dbx-tools) is only needed to
201
232
  bootstrap a folder that has no `.projenrc.ts` or toolchain yet.
202
233
 
203
234
  ## Run Tasks From The ROOT
@@ -206,14 +237,15 @@ Every repo-wide task lives on the root, and the root's `compile` / `test`
206
237
  delegate with `bun run --filter '*'` rather than emitting a step per member - so
207
238
  a new package is covered without a re-synth. Work from the root:
208
239
 
209
- | Task | What it does |
210
- | ----------------- | -------------------------------------------------- |
211
- | `bun run build` | `compile` + `test` + `package` across every member |
212
- | `bun run compile` | `tsc --build` in each member, in parallel |
213
- | `bun run test` | `eslint` once, then each member's tests |
214
- | `bun run sync` | re-synth (`--watch` to keep synthing) |
215
- | `bun run barrels` | regenerate the read-only `index.ts` barrels |
216
- | `bun run bump` | version, tag, and publish |
240
+ | Task | What it does |
241
+ | --------------------- | -------------------------------------------------- |
242
+ | `bun run build` | `compile` + `test` + `package` across every member |
243
+ | `bun run compile` | `tsc --build` in each member, in parallel |
244
+ | `bun run test` | `eslint` once, then each member's tests |
245
+ | `bun run sync` | re-synth (`--watch` to keep synthing) |
246
+ | `bun run barrels` | regenerate the read-only `index.ts` barrels |
247
+ | `bun run rs-packages` | regenerate Node/Python Rosetta outputs |
248
+ | `bun run bump` | version, tag, and publish |
217
249
 
218
250
  Members intentionally keep only the tasks that something OTHER than a human
219
251
  invokes, so there is no second place to run the same thing:
package/index.ts CHANGED
@@ -18,11 +18,13 @@ export * as project from "./src/project.ts";
18
18
  export * as projectPredicate from "./src/project-predicate.ts";
19
19
  export * as publish from "./src/publish.ts";
20
20
  export * as release from "./src/release.ts";
21
+ export * as rsPackages from "./src/rs-packages.ts";
21
22
  export * as scaffold from "./src/scaffold.ts";
22
23
  export * as tags from "./src/tags.ts";
23
24
  export * as tsconfig from "./src/tsconfig.ts";
24
25
  export * as vscode from "./src/vscode.ts";
25
26
  export * as watch from "./src/watch.ts";
27
+ export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
26
28
  export { DBXToolsConfig } from "./src/dbx-tools-config.ts";
27
29
  export type { DBXToolsConfigOptions } from "./src/dbx-tools-config.ts";
28
30
  export { resolvePkgRoot } from "./src/engine-root.ts";
@@ -38,9 +40,10 @@ export type { DBXToolsProject, DBXToolsProjectOptions, DBXToolsTypeScriptProject
38
40
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
39
41
  export { DBXToolsRelease } from "./src/release.ts";
40
42
  export type { StandaloneRelease, DBXToolsReleaseOptions } from "./src/release.ts";
43
+ export { DEFAULT_RS_PACKAGE_ROOTS, RsPackages } from "./src/rs-packages.ts";
44
+ export type { RsPackageOutput, GenerateRsPackagesOptions } from "./src/rs-packages.ts";
41
45
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
42
46
  export type { PackageTag } from "./src/tags.ts";
43
47
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
44
- export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
45
48
  export { DBXToolsVsCode } from "./src/vscode.ts";
46
49
  export type { IgnoreGroupOptions } from "./src/watch.ts";
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.58",
30
- "@dbx-tools/path": "0.6.58",
31
- "@dbx-tools/shared-core": "0.6.58",
29
+ "@dbx-tools/core": "0.6.60",
30
+ "@dbx-tools/path": "0.6.60",
31
+ "@dbx-tools/shared-core": "0.6.60",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.58",
50
+ "version": "0.6.60",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/project.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  RootBunfigFile,
31
31
  } from "./bun-app.ts";
32
32
  import { DBXToolsVsCode } from "./vscode.ts";
33
+ import { DEFAULT_RS_PACKAGE_ROOTS, RsPackages } from "./rs-packages.ts";
33
34
  import {
34
35
  DEFAULT_PACKAGE_ROOTS,
35
36
  type DiscoveredPackage,
@@ -519,6 +520,11 @@ export interface DBXToolsProjectOptions
519
520
  * is a member of the single bun workspace, so the root links it from source.
520
521
  */
521
522
  readonly extraWorkspaceMembers?: readonly string[];
523
+ /**
524
+ * Experimental TypeScript-to-Node/Python source roots. Defaults to
525
+ * `["rs-packages"]`; set `false` to disable the generator.
526
+ */
527
+ readonly rsPackageRoots?: readonly string[] | false;
522
528
  }
523
529
 
524
530
  /** Options for {@link DBXToolsTypeScriptProject} (a package, or a compiling root). */
@@ -841,6 +847,7 @@ function registerRootTasks(project: javascript.NodeProject): void {
841
847
  applyTasks(project, {
842
848
  barrels: { exec: taskScript(project, "barrels.ts") },
843
849
  openapi: { exec: taskScript(project, "openapi.ts") },
850
+ "rs-packages": { exec: taskScript(project, "rs-packages.ts"), receiveArgs: true },
844
851
  clean: { exec: taskScript(project, "clean.ts"), receiveArgs: true },
845
852
  // `receiveArgs` forwards `--watch`, so `bun run sync -- --watch` syncs once
846
853
  // then starts the single node-path watcher loop.
@@ -925,6 +932,13 @@ function initProject(
925
932
  project.vsCode = new DBXToolsVsCode(project);
926
933
 
927
934
  registerRootTasks(project);
935
+ project.dbxToolsConfig.rsPackageRoots =
936
+ options.rsPackageRoots === false
937
+ ? false
938
+ : [...(options.rsPackageRoots ?? DEFAULT_RS_PACKAGE_ROOTS)];
939
+ if (options.rsPackageRoots !== false) {
940
+ new RsPackages(project, options.rsPackageRoots ?? DEFAULT_RS_PACKAGE_ROOTS);
941
+ }
928
942
  if (options.prettier || project.prettier) {
929
943
  const formatTask = project.tasks.tryFind("format") ?? project.addTask("format");
930
944
  formatTask.prependExec("prettier . --write", { receiveArgs: true });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Experimental Rosetta-source generation.
3
+ *
4
+ * A TypeScript file under `rs-packages/<package>/src/` is the canonical Node
5
+ * implementation. Projen copies its TypeScript surface to the matching
6
+ * `packages/<package>/src/` path and extracts one or more `@rs-python` blocks
7
+ * into a conventional Python package path.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
10
+ import { dirname, extname, join, relative, resolve } from "node:path";
11
+ import { json, object } from "@dbx-tools/shared-core";
12
+ import { Component, javascript } from "projen";
13
+ import { header, makeReadonly, makeWritable } from "./generated.ts";
14
+ import { toPosix } from "./packages.ts";
15
+
16
+ export const DEFAULT_RS_PACKAGE_ROOTS = ["rs-packages"] as const;
17
+
18
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
19
+ const PYTHON_BLOCK = /\/\*\s*@rs-python\s*\n([\s\S]*?)\n\s*@rs-end\s*\*\//g;
20
+ const NODE_OVERRIDE = /^\s*\/\/\s*@rs-node\s+(.+?)\s*$/m;
21
+ const PYTHON_OVERRIDE = /^\s*\/\/\s*@rs-python-path\s+(.+?)\s*$/m;
22
+ const RS_METADATA = /^\s*\/\/\s*@rs-(?:node|python-path)\s+.+?\s*$\n?/gm;
23
+ const NODE_MARKER = "// GENERATED by projen synth (rs-packages)";
24
+ const PYTHON_MARKER = "# GENERATED by projen synth (rs-packages)";
25
+
26
+ export interface RsPackageOutput {
27
+ readonly source: string;
28
+ readonly node: string;
29
+ readonly python: string;
30
+ }
31
+
32
+ export interface GenerateRsPackagesOptions {
33
+ readonly projectRoot?: string;
34
+ readonly roots?: readonly string[];
35
+ }
36
+
37
+ function sourceFiles(root: string): string[] {
38
+ if (!existsSync(root)) return [];
39
+ const files: string[] = [];
40
+ const visit = (dir: string): void => {
41
+ for (const entry of readdirSync(dir)) {
42
+ const path = join(dir, entry);
43
+ if (statSync(path).isDirectory()) visit(path);
44
+ else if (SOURCE_EXTENSIONS.has(extname(entry))) files.push(path);
45
+ }
46
+ };
47
+ visit(root);
48
+ return files.sort();
49
+ }
50
+
51
+ function dedent(value: string): string {
52
+ const lines = value.replace(/^\n+|\n+$/g, "").split("\n");
53
+ const indents = lines
54
+ .filter((line) => line.trim())
55
+ .map((line) => line.match(/^\s*/)?.[0].length ?? 0);
56
+ const width = indents.length ? Math.min(...indents) : 0;
57
+ return lines.map((line) => line.slice(width)).join("\n");
58
+ }
59
+
60
+ function defaultOutputPaths(
61
+ projectRoot: string,
62
+ root: string,
63
+ source: string,
64
+ ): Pick<RsPackageOutput, "node" | "python"> {
65
+ const sourceRelative = toPosix(relative(resolve(projectRoot, root), source));
66
+ const segments = sourceRelative.split("/");
67
+ const srcIndex = segments.indexOf("src");
68
+ if (srcIndex < 1) {
69
+ throw new Error(
70
+ `Rosetta source must live under <package>/src/: ${toPosix(relative(projectRoot, source))}`,
71
+ );
72
+ }
73
+
74
+ const packageSegments = segments.slice(0, srcIndex);
75
+ const moduleSegments = segments.slice(srcIndex + 1);
76
+ const moduleFile = moduleSegments.pop();
77
+ if (!moduleFile) throw new Error(`Rosetta source has no module name: ${source}`);
78
+
79
+ const pythonDistribution = packageSegments.join("-");
80
+ const pythonImport = packageSegments.join("_").replace(/-/g, "_");
81
+ const pythonModule = `${moduleFile.slice(0, -extname(moduleFile).length).replace(/-/g, "_")}.py`;
82
+
83
+ return {
84
+ node: resolve(
85
+ projectRoot,
86
+ "packages",
87
+ ...packageSegments,
88
+ "src",
89
+ ...moduleSegments,
90
+ moduleFile,
91
+ ),
92
+ python: resolve(
93
+ projectRoot,
94
+ "python-packages",
95
+ pythonDistribution,
96
+ "src",
97
+ pythonImport,
98
+ ...moduleSegments,
99
+ pythonModule,
100
+ ),
101
+ };
102
+ }
103
+
104
+ function resolveOutput(
105
+ projectRoot: string,
106
+ override: RegExpMatchArray | null,
107
+ fallback: string,
108
+ ): string {
109
+ const value = override?.[1]?.trim();
110
+ return value ? resolve(projectRoot, value) : fallback;
111
+ }
112
+
113
+ function assertWritableOutput(projectRoot: string, path: string, marker: string): void {
114
+ if (!existsSync(path)) return;
115
+ if (readFileSync(path, "utf8").startsWith(marker)) return;
116
+ throw new Error(
117
+ `Refusing to overwrite hand-authored Rosetta output: ${toPosix(relative(projectRoot, path))}`,
118
+ );
119
+ }
120
+
121
+ function renderSource(projectRoot: string, root: string, source: string): RsPackageOutput {
122
+ const input = readFileSync(source, "utf8");
123
+ const defaults = defaultOutputPaths(projectRoot, root, source);
124
+ const node = resolveOutput(projectRoot, input.match(NODE_OVERRIDE), defaults.node);
125
+ const python = resolveOutput(projectRoot, input.match(PYTHON_OVERRIDE), defaults.python);
126
+ const pythonBlocks = [...input.matchAll(PYTHON_BLOCK)].map((match) => dedent(match[1] ?? ""));
127
+
128
+ if (!pythonBlocks.length) {
129
+ throw new Error(
130
+ `Rosetta source needs at least one /* @rs-python ... @rs-end */ block: ${toPosix(relative(projectRoot, source))}`,
131
+ );
132
+ }
133
+
134
+ const nodeBody = input.replace(PYTHON_BLOCK, "").replace(RS_METADATA, "").trim();
135
+ const nodeContent = `${header({
136
+ tool: "projen synth (rs-packages)",
137
+ source: toPosix(relative(projectRoot, source)),
138
+ })}\n${nodeBody}\n`;
139
+ const pythonContent = [
140
+ "# GENERATED by projen synth (rs-packages) - DO NOT EDIT.",
141
+ `# Regenerated from ${toPosix(relative(projectRoot, source))}.`,
142
+ "# Hand edits are overwritten; edit the Rosetta source instead.",
143
+ "",
144
+ pythonBlocks.join("\n\n"),
145
+ "",
146
+ ].join("\n");
147
+
148
+ for (const [path, content] of [
149
+ [node, nodeContent],
150
+ [python, pythonContent],
151
+ ] as const) {
152
+ assertWritableOutput(projectRoot, path, path === node ? NODE_MARKER : PYTHON_MARKER);
153
+ mkdirSync(dirname(path), { recursive: true });
154
+ makeWritable(path);
155
+ writeFileSync(path, content);
156
+ makeReadonly(path);
157
+ }
158
+
159
+ return { source, node, python };
160
+ }
161
+
162
+ /** Read the roots recorded by synth for standalone tasks and watchers. */
163
+ export function configuredRsPackageRoots(projectRoot: string): readonly string[] | false {
164
+ const manifest = json.parseRecord(readFileSync(resolve(projectRoot, "package.json"), "utf8"));
165
+ const config = object.isRecord(manifest?.dbxToolsConfig) ? manifest.dbxToolsConfig : undefined;
166
+ const configured = config?.rsPackageRoots;
167
+ if (configured === false) return false;
168
+ if (Array.isArray(configured)) {
169
+ const roots = configured.filter(
170
+ (value): value is string => typeof value === "string" && !!value,
171
+ );
172
+ if (roots.length) return roots;
173
+ }
174
+ return DEFAULT_RS_PACKAGE_ROOTS;
175
+ }
176
+
177
+ export function discoverRsPackageOutputs(
178
+ options: GenerateRsPackagesOptions = {},
179
+ ): RsPackageOutput[] {
180
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
181
+ const roots = options.roots ?? DEFAULT_RS_PACKAGE_ROOTS;
182
+ return roots.flatMap((root) =>
183
+ sourceFiles(resolve(projectRoot, root)).map((source) => {
184
+ const defaults = defaultOutputPaths(projectRoot, root, source);
185
+ const input = readFileSync(source, "utf8");
186
+ return {
187
+ source,
188
+ node: resolveOutput(projectRoot, input.match(NODE_OVERRIDE), defaults.node),
189
+ python: resolveOutput(projectRoot, input.match(PYTHON_OVERRIDE), defaults.python),
190
+ };
191
+ }),
192
+ );
193
+ }
194
+
195
+ export function generateRsPackages(options: GenerateRsPackagesOptions = {}): RsPackageOutput[] {
196
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
197
+ const roots = options.roots ?? DEFAULT_RS_PACKAGE_ROOTS;
198
+ return roots.flatMap((root) =>
199
+ sourceFiles(resolve(projectRoot, root)).map((source) =>
200
+ renderSource(projectRoot, root, source),
201
+ ),
202
+ );
203
+ }
204
+
205
+ /** Projen lifecycle component that regenerates Rosetta outputs after synth. */
206
+ export class RsPackages extends Component {
207
+ private readonly outputs: RsPackageOutput[];
208
+
209
+ constructor(
210
+ project: javascript.NodeProject,
211
+ private readonly roots: readonly string[] = DEFAULT_RS_PACKAGE_ROOTS,
212
+ ) {
213
+ super(project);
214
+ // Generate during construction so a brand-new Node package exists before the
215
+ // root's package scan later in the same synth. postSynthesize repeats this to
216
+ // restore outputs after projen has written the rest of the tree.
217
+ this.outputs = generateRsPackages({ projectRoot: project.outdir, roots });
218
+ for (const output of this.outputs) {
219
+ const node = `/${toPosix(relative(project.outdir, output.node))}`;
220
+ const python = `/${toPosix(relative(project.outdir, output.python))}`;
221
+ project.annotateGenerated(node);
222
+ project.annotateGenerated(python);
223
+ }
224
+ }
225
+
226
+ public override preSynthesize(): void {
227
+ const eslint = javascript.Eslint.of(this.project);
228
+ if (!eslint) return;
229
+ for (const output of this.outputs) {
230
+ eslint.addIgnorePattern(toPosix(relative(this.project.outdir, output.node)));
231
+ }
232
+ }
233
+
234
+ public override postSynthesize(): void {
235
+ generateRsPackages({ projectRoot: this.project.outdir, roots: this.roots });
236
+ }
237
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { log } from "@dbx-tools/shared-core";
4
+ import { configuredRsPackageRoots, generateRsPackages } from "../src/rs-packages.ts";
5
+ import { repoRoot } from "../src/packages.ts";
6
+ import { watchLoop } from "../src/watch.ts";
7
+
8
+ const logger = log.logger("projen:rs-packages");
9
+ const configuredRoots = configuredRsPackageRoots(repoRoot);
10
+ const roots = configuredRoots === false ? [] : configuredRoots;
11
+ const watchRoots = roots.map((root) => resolve(repoRoot, root)).filter(existsSync);
12
+
13
+ function generate(): void {
14
+ const outputs = generateRsPackages({ projectRoot: repoRoot, roots });
15
+ logger.success(`generated ${outputs.length} Rosetta module${outputs.length === 1 ? "" : "s"}`);
16
+ }
17
+
18
+ generate();
19
+
20
+ if (process.argv.includes("--watch")) {
21
+ if (watchRoots.length) watchLoop("rs-packages", watchRoots, generate);
22
+ else {
23
+ logger.info("no Rosetta roots configured; watcher is idle");
24
+ setInterval(() => {}, 2_147_483_647);
25
+ }
26
+ }
package/tasks/sync.ts CHANGED
@@ -31,7 +31,7 @@ if (!process.argv.includes("--watch")) {
31
31
  runSynth({ post: true });
32
32
  logger.success("synced");
33
33
  } else {
34
- // Watch: one initial full synth to bring the tree up to date, then three focused
34
+ // Watch: one initial full synth to bring the tree up to date, then focused
35
35
  // watchers under `concurrently`. The projenrc watcher is the intelligent stand-in
36
36
  // for stock `projen --watch` - it re-synths (+install) ONLY when `.projenrc.ts` or
37
37
  // a configured `syncResynthPaths` entry changes, while barrels/openapi keep generated
@@ -57,6 +57,11 @@ if (!process.argv.includes("--watch")) {
57
57
  { command: `bun "${taskPath("projenrc.ts")}"`, name: "projenrc", prefixColor: "magenta" },
58
58
  { command: `bun "${taskPath("barrels.ts")}" --watch`, name: "barrels", prefixColor: "cyan" },
59
59
  { command: `bun "${taskPath("openapi.ts")}" --watch`, name: "openapi", prefixColor: "green" },
60
+ {
61
+ command: `bun "${taskPath("rs-packages.ts")}" --watch`,
62
+ name: "rs-packages",
63
+ prefixColor: "yellow",
64
+ },
60
65
  ],
61
66
  {
62
67
  prefix: "name",