@dbx-tools/projen 0.9.61 → 0.9.64

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
@@ -205,9 +205,11 @@ The workspace creates one repository-wide binding lifecycle:
205
205
  - `python-node-bindings` generates every configured bridge.
206
206
  - `python-node-bindings:check` verifies generated files and fails on stale
207
207
  `_generated/node` directories.
208
- - `python-node-bindings:watch` watches all configured bridges and regenerates
209
- only Python projects affected by a Node package, transitive workspace
210
- dependency, binding manifest, or function override change.
208
+ - `python-node-bindings:watch` watches only configured module entrypoints and
209
+ their transitive workspace source imports. Tests, compiled output, generated
210
+ bindings, and unrelated modules do not trigger regeneration or acquire the
211
+ workspace mutation lock. Binding-manifest and generator changes regenerate
212
+ the affected projects and restart the watcher when its input graph changes.
211
213
 
212
214
  When a runtime owner is configured, the workspace also creates one shared
213
215
  runtime lifecycle:
@@ -483,6 +485,7 @@ bun run release --publish local --install never
483
485
  bun run release --local-registry false --local-pypi auto
484
486
  bun run release --demo-deploy
485
487
  bun run release --no-release-notes
488
+ bun run release --release-notes-instructions "Focus on operator-visible changes."
486
489
  ```
487
490
 
488
491
  `--demo-deploy` is off by default. After tagging and any local registry
@@ -493,14 +496,17 @@ recorded in the annotated tag.
493
496
  After bump, release writes `docs/releases/vX.Y.Z.md` with `dbx genie exec -C
494
497
  "$PWD" --sandbox read-only --ephemeral -o`. If Genie fails or writes an empty
495
498
  file, the step keeps going with a short git-log summary. `--no-release-notes`
496
- skips the file. Notes are committed with the version bump and are not recorded
497
- in the annotated tag. They are skipped on `--no-bump` because that path
498
- requires an already committed tree.
499
+ skips the file. `--release-notes-instructions` appends run-specific guidance to
500
+ the standard Genie prompt. Notes are committed with the version bump and are not
501
+ recorded in the annotated tag. They are skipped on `--no-bump` because that
502
+ path requires an already committed tree.
499
503
 
500
504
  `--install auto` keeps Projen's normal local dependency-install behavior;
501
505
  `always` performs a manifest-resolved install first, and `never` uses dependencies already
502
- installed while still regenerating versioned sources. `--no-validation` skips
503
- optional task checks locally and in CI, not version or immutable-source checks.
506
+ installed while still regenerating versioned sources. Local release validation
507
+ runs `eslint:fix` before branch preparation and bump, then runs the selected
508
+ fail-closed tasks; CI remains check-only.
509
+ `--no-validation` skips optional task checks locally and in CI, not version or immutable-source checks.
504
510
  `--no-docs` skips building and deploying the site, not package README validation.
505
511
  Scopes select artifacts, not the normal commit and tag-push transaction.
506
512
  The annotated tag records CI step selections, so publishing a selected scope does
@@ -568,6 +574,7 @@ release [options]
568
574
  | `--no-docs` | skip documentation build and deployment |
569
575
  | `--no-validation` | skip optional release validation tasks; version/source checks remain mandatory |
570
576
  | `--no-release-notes` | skip writing docs/releases notes (Genie and git-log fallback) |
577
+ | `--release-notes-instructions <text>` | append custom instructions to the Genie release-notes prompt |
571
578
  | `--demo-deploy` | after tagging, stage and deploy the AppKit demo app (off by default) |
572
579
  | `--no-local-publish` | skip publishing to configured local registries |
573
580
  | `--local-registry <auto\|false\|url>` | local npm registry selection (default: "auto") |
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.61";
6
+ export const PACKAGE_VERSION = "0.9.64";
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";
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.61",
34
- "@dbx-tools/path": "^0.9.61",
35
- "@dbx-tools/shared-core": "^0.9.61",
33
+ "@dbx-tools/core": "^0.9.64",
34
+ "@dbx-tools/path": "^0.9.64",
35
+ "@dbx-tools/shared-core": "^0.9.64",
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.61",
57
+ "version": "0.9.64",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
@@ -1,10 +1,11 @@
1
1
  /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
- import { readFileSync, rmSync } from "node:fs";
2
+ import { readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { find } from "@dbx-tools/path";
6
6
  import { object, stringUtils } from "@dbx-tools/shared-core";
7
7
  import { parse } from "smol-toml";
8
+ import ts from "typescript";
8
9
  import { runTaskCommand } from "./_task-command.ts";
9
10
  import { resolveRepoRoot, workspaceDependencyDirectories } from "./packages.ts";
10
11
 
@@ -29,6 +30,7 @@ export interface ResolvedPythonNodeBindings {
29
30
  readonly projectDirectory: string;
30
31
  readonly pyproject: string;
31
32
  readonly runtimeOutput: string;
33
+ readonly watchInputs: readonly string[];
32
34
  readonly workspaceDirectories: readonly string[];
33
35
  }
34
36
 
@@ -152,7 +154,7 @@ export function resolvePythonNodeBindings(
152
154
  }
153
155
  const bindingName = nodePackageName(packageName);
154
156
  const bindingDirectory = join(moduleDirectory, "_generated", "node", bindingName);
155
- return {
157
+ const resolved = {
156
158
  project,
157
159
  projectDirectory,
158
160
  pyproject,
@@ -177,11 +179,85 @@ export function resolvePythonNodeBindings(
177
179
  }),
178
180
  workspaceDirectories: workspaceDependencyDirectories(packageName, root),
179
181
  };
182
+ return {
183
+ ...resolved,
184
+ watchInputs: bindingSourceInputs(root, resolved),
185
+ };
180
186
  });
181
187
  validateBindings(configs);
182
188
  return configs;
183
189
  }
184
190
 
191
+ type BindingSourceConfig = Omit<ResolvedPythonNodeBindings, "watchInputs">;
192
+
193
+ /** Transitive workspace source files reachable from configured binding modules. */
194
+ function bindingSourceInputs(root: string, config: BindingSourceConfig): string[] {
195
+ const allowed = config.workspaceDirectories.map(canonicalPath);
196
+ const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
197
+ const entrypoints =
198
+ config.modules.length > 0
199
+ ? config.modules.map((module) => resolvePythonNodeBindingModule(packageEntrypoint, module))
200
+ : [packageEntrypoint];
201
+ const pending = [
202
+ ...entrypoints,
203
+ ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
204
+ ];
205
+ const visited = new Set<string>();
206
+ while (pending.length > 0) {
207
+ const candidate = canonicalPath(pending.pop()!);
208
+ if (
209
+ visited.has(candidate) ||
210
+ !allowed.some((directory) => containsResolvedPath(directory, candidate))
211
+ ) {
212
+ continue;
213
+ }
214
+ visited.add(candidate);
215
+ let source: string;
216
+ try {
217
+ source = readFileSync(candidate, "utf8");
218
+ } catch {
219
+ continue;
220
+ }
221
+ const dependencies = ts.preProcessFile(source, true, true).importedFiles;
222
+ for (const dependency of dependencies) {
223
+ try {
224
+ pending.push(Bun.resolveSync(dependency.fileName, dirname(candidate)));
225
+ } catch {
226
+ // Built-ins and external packages are outside the workspace source graph.
227
+ }
228
+ }
229
+ }
230
+ return [...visited].sort().map((path) => relative(root, path));
231
+ }
232
+
233
+ /** Resolve one configured namespace from a package entrypoint barrel. */
234
+ export function resolvePythonNodeBindingModule(entrypoint: string, module: string): string {
235
+ if (!/^[$A-Z_a-z][$\w]*$/.test(module)) {
236
+ throw new Error(`Node binding module must be a TypeScript identifier: ${module}`);
237
+ }
238
+ const source = readFileSync(entrypoint, "utf8");
239
+ const escaped = module.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
240
+ const match = new RegExp(`export\\s+\\*\\s+as\\s+${escaped}\\s+from\\s+["']([^"']+)["']`).exec(
241
+ source,
242
+ );
243
+ if (!match?.[1]) {
244
+ throw new Error(`${entrypoint} does not export namespace ${module}`);
245
+ }
246
+ return Bun.resolveSync(match[1], dirname(entrypoint));
247
+ }
248
+
249
+ function canonicalPath(path: string): string {
250
+ try {
251
+ return realpathSync(path);
252
+ } catch {
253
+ return resolve(path);
254
+ }
255
+ }
256
+
257
+ function containsResolvedPath(parent: string, candidate: string): boolean {
258
+ return candidate === parent || candidate.startsWith(`${parent}${sep}`);
259
+ }
260
+
185
261
  /** All paths that can change a workspace-backed generated Node runtime. */
186
262
  export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings): string[] {
187
263
  if (config.workspaceDirectories.length === 0) {
@@ -189,7 +265,7 @@ export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings)
189
265
  }
190
266
  return [
191
267
  config.pyproject,
192
- ...config.workspaceDirectories,
268
+ ...config.watchInputs,
193
269
  ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
194
270
  ];
195
271
  }
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Keep every configured Python Node bridge current with one repository watcher. */
3
- import { dirname, resolve, sep } from "node:path";
3
+ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { log, object } from "@dbx-tools/shared-core";
5
+ import { log } 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 {
@@ -12,36 +12,24 @@ import {
12
12
  resolveAllPythonNodeBindings,
13
13
  type ResolvedPythonNodeBindings,
14
14
  } from "../src/python-node-bindings.ts";
15
- import { watchLoop, watchRoots } from "../src/watch.ts";
15
+ import { watchLoop } from "../src/watch.ts";
16
16
  import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
17
17
 
18
18
  const logger = log.logger("projen:python-node-bindings-watch");
19
19
  const generator = fileURLToPath(new URL("./python-node-bindings.ts", import.meta.url));
20
+ const watcher = fileURLToPath(import.meta.url);
21
+ const bindingSource = fileURLToPath(new URL("../src/python-node-bindings.ts", import.meta.url));
22
+ const toolingInputs = [generator, watcher, bindingSource];
20
23
 
21
24
  function generate(project: string): void {
22
25
  runTaskCommand(repoRoot, "bun", [generator, "--root", repoRoot, "--project", project]);
23
26
  }
24
27
 
25
- function containsPath(parent: string, candidate: string): boolean {
26
- const resolvedParent = resolve(parent);
27
- const resolvedCandidate = resolve(candidate);
28
- return (
29
- resolvedCandidate === resolvedParent || resolvedCandidate.startsWith(`${resolvedParent}${sep}`)
30
- );
31
- }
32
-
33
28
  /**
34
- * Watch broad package roots, each configured Python workspace root, and only
35
- * exceptional inputs such as function overrides that live outside those roots.
29
+ * Watch only configured manifests and transitive workspace source inputs.
36
30
  */
37
31
  function watchPaths(configs: readonly ResolvedPythonNodeBindings[]): string[] {
38
- const roots = watchRoots();
39
- const pythonRoots = configs.map(({ projectDirectory }) => dirname(projectDirectory));
40
- const covered = [...roots, ...pythonRoots];
41
- const explicitInputs = configs
42
- .flatMap(pythonNodeBindingWatchInputs)
43
- .filter((input) => !covered.some((root) => containsPath(root, input)));
44
- return [...object.sequence(covered, explicitInputs).distinct()].sort();
32
+ return [...new Set([...configs.flatMap(pythonNodeBindingWatchInputs), ...toolingInputs])].sort();
45
33
  }
46
34
 
47
35
  function selection(
@@ -51,14 +39,20 @@ function selection(
51
39
  readonly all: boolean;
52
40
  readonly configs: ResolvedPythonNodeBindings[];
53
41
  readonly projects: string[];
42
+ readonly restart: boolean;
54
43
  } {
55
44
  const configs = resolveAllPythonNodeBindings(repoRoot);
56
45
  const manifests = new Set([...previous, ...configs].map(({ pyproject }) => resolve(pyproject)));
57
- const all = changed.some((path) => manifests.has(resolve(path)));
46
+ const all = changed.some(
47
+ (path) =>
48
+ manifests.has(resolve(path)) ||
49
+ toolingInputs.some((input) => resolve(input) === resolve(path)),
50
+ );
58
51
  return {
59
52
  all,
60
53
  configs,
61
54
  projects: all ? [] : affectedPythonNodeBindingProjects(repoRoot, configs, changed),
55
+ restart: watchPaths(previous).join("\n") !== watchPaths(configs).join("\n"),
62
56
  };
63
57
  }
64
58
 
@@ -77,11 +71,15 @@ export async function main(): Promise<void> {
77
71
  if (next.all) {
78
72
  generatePythonNodeBindings(repoRoot);
79
73
  logger.success("regenerated all Python Node bindings");
80
- return;
74
+ } else {
75
+ for (const project of next.projects) {
76
+ generate(project);
77
+ logger.success(`regenerated ${project} Node bindings`);
78
+ }
81
79
  }
82
- for (const project of next.projects) {
83
- generate(project);
84
- logger.success(`regenerated ${project} Node bindings`);
80
+ if (next.restart) {
81
+ logger.info("binding inputs changed; restarting watcher");
82
+ setTimeout(() => process.exit(0), 0);
85
83
  }
86
84
  },
87
85
  {
@@ -16,6 +16,7 @@ import { publicFunctionExports, publicNamespaceExports } from "../src/module-exp
16
16
  import { resolveRepoRoot } from "../src/packages.ts";
17
17
  import {
18
18
  generatePythonNodeBindings,
19
+ resolvePythonNodeBindingModule,
19
20
  resolvePythonNodeBindings,
20
21
  type ResolvedPythonNodeBindings,
21
22
  type ResolvedPythonNodeFunctionOverride,
@@ -139,7 +140,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
139
140
  );
140
141
  const packageEntrypoint = Bun.resolveSync(config.entrypoint, projectDirectory);
141
142
  const entrypoint = selectedModule
142
- ? resolveGeneratedModule(packageEntrypoint, selectedModule)
143
+ ? resolvePythonNodeBindingModule(packageEntrypoint, selectedModule)
143
144
  : packageEntrypoint;
144
145
  const functions = publicFunctionExports(entrypoint);
145
146
  if (functions.length === 0) {
@@ -217,7 +218,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
217
218
  return binding.modules.length > 0
218
219
  ? binding.modules.map((module) => ({
219
220
  key: runtimeModuleName(binding, module),
220
- entrypoint: resolveGeneratedModule(entrypoint, module),
221
+ entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
221
222
  }))
222
223
  : [{ key: runtimeModuleName(binding), entrypoint }];
223
224
  });
@@ -310,7 +311,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
310
311
  const packageEntrypoint = Bun.resolveSync(candidate.entrypoint, candidate.projectDirectory);
311
312
  const modules = publicNamespaceExports(packageEntrypoint).filter(
312
313
  (module) =>
313
- publicFunctionExports(resolveGeneratedModule(packageEntrypoint, module)).length > 0,
314
+ publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
314
315
  );
315
316
  return { ...candidate, modules };
316
317
  }
@@ -1220,19 +1221,4 @@ function pythonRuntimeLoader(source: string): string {
1220
1221
  ].join("\n");
1221
1222
  }
1222
1223
 
1223
- function resolveGeneratedModule(entrypoint: string, module: string): string {
1224
- if (!/^[$A-Z_a-z][$\w]*$/.test(module)) {
1225
- throw new Error(`Node binding module must be a TypeScript identifier: ${module}`);
1226
- }
1227
- const source = readFileSync(entrypoint, "utf8");
1228
- const escaped = module.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
1229
- const match = new RegExp(`export\\s+\\*\\s+as\\s+${escaped}\\s+from\\s+["']([^"']+)["']`).exec(
1230
- source,
1231
- );
1232
- if (!match?.[1]) {
1233
- throw new Error(`${entrypoint} does not export namespace ${module}`);
1234
- }
1235
- return Bun.resolveSync(match[1], dirname(entrypoint));
1236
- }
1237
-
1238
1224
  if (import.meta.main) await main();
@@ -20,6 +20,8 @@ const GENIE_PROMPT =
20
20
 
21
21
  /** Inputs for one notes write. */
22
22
  export interface WriteReleaseNotesOptions {
23
+ /** Extra agent instructions appended after the standard release-note requirements. */
24
+ readonly instructions?: string;
23
25
  readonly prefix: string;
24
26
  readonly root: string;
25
27
  readonly version: string;
@@ -27,6 +29,12 @@ export interface WriteReleaseNotesOptions {
27
29
  readonly runGenie?: (args: readonly string[]) => boolean;
28
30
  }
29
31
 
32
+ /** Compose the stable release-note prompt with optional caller guidance. */
33
+ export function releaseNotesPrompt(instructions?: string): string {
34
+ const additional = instructions?.trim();
35
+ return additional ? `${GENIE_PROMPT}\n\nAdditional instructions:\n${additional}` : GENIE_PROMPT;
36
+ }
37
+
30
38
  /** Path of the notes file for one workspace version. */
31
39
  export function releaseNotesPath(root: string, version: string): string {
32
40
  return join(root, "docs", "releases", `v${version}.md`);
@@ -61,7 +69,7 @@ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
61
69
  "--ephemeral",
62
70
  "-o",
63
71
  relative,
64
- GENIE_PROMPT,
72
+ releaseNotesPrompt(options.instructions),
65
73
  ] as const;
66
74
  const ran =
67
75
  options.runGenie?.([...genieArgs]) ??
package/tasks/release.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Run the configured release transaction. */
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import * as projectUtils from "@dbx-tools/core/project-utils";
4
6
  import { log } from "@dbx-tools/shared-core";
5
7
  import { Command, Option } from "commander";
@@ -20,6 +22,14 @@ import { readWorkspaceVersion } from "../src/workspace-version.ts";
20
22
 
21
23
  const logger = log.logger("projen:release");
22
24
 
25
+ /** Whether the root manifest exposes one repository-owned script. */
26
+ function hasPackageScript(root: string, name: string): boolean {
27
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
28
+ scripts?: Record<string, string>;
29
+ };
30
+ return Boolean(manifest.scripts?.[name]);
31
+ }
32
+
23
33
  function gitHead(root: string, ref: string): string | undefined {
24
34
  if (!taskCommandSucceeds(root, "git", ["rev-parse", "--verify", ref])) return undefined;
25
35
  return captureTaskCommand(root, "git", ["rev-parse", ref], { check: true });
@@ -110,6 +120,8 @@ export async function runRelease(
110
120
  readonly demoDeploy?: boolean;
111
121
  /** Write `docs/releases/vX.Y.Z.md` via Genie, with a git-log fallback. On by default. */
112
122
  readonly releaseNotes?: boolean;
123
+ /** Additional instructions appended to the standard Genie release-notes prompt. */
124
+ readonly releaseNotesInstructions?: string;
113
125
  readonly writeReleaseNotes?: typeof writeReleaseNotes;
114
126
  },
115
127
  ): Promise<string> {
@@ -118,6 +130,9 @@ export async function runRelease(
118
130
  const install = options.install ?? "auto";
119
131
 
120
132
  if (install === "always") runTaskCommand(root, "bun", ["install"]);
133
+ if (selection.validation && hasPackageScript(root, "eslint:fix")) {
134
+ runTaskCommand(root, "bun", ["run", "eslint:fix"]);
135
+ }
121
136
 
122
137
  prepareReleaseBranch({ branch, remote, root });
123
138
 
@@ -132,7 +147,12 @@ export async function runRelease(
132
147
  const tag = `${prefix}${version}`;
133
148
  assertReleaseVersion(version, { root, prefixes: [prefix], assertNext: true });
134
149
  if ((options.releaseNotes ?? true) && (options.bump ?? true)) {
135
- (options.writeReleaseNotes ?? writeReleaseNotes)({ prefix, root, version });
150
+ (options.writeReleaseNotes ?? writeReleaseNotes)({
151
+ prefix,
152
+ root,
153
+ version,
154
+ instructions: options.releaseNotesInstructions,
155
+ });
136
156
  }
137
157
 
138
158
  if (taskCommandSucceeds(root, "git", ["rev-parse", "--verify", `refs/tags/${tag}`])) {
@@ -242,6 +262,10 @@ export function createReleaseCommand(): Command {
242
262
  "skip optional release validation tasks; version/source checks remain mandatory",
243
263
  )
244
264
  .option("--no-release-notes", "skip writing docs/releases notes (Genie and git-log fallback)")
265
+ .option(
266
+ "--release-notes-instructions <text>",
267
+ "append custom instructions to the Genie release-notes prompt",
268
+ )
245
269
  .option("--demo-deploy", "after tagging, stage and deploy the AppKit demo app (off by default)")
246
270
  .option("--no-local-publish", "skip publishing to configured local registries")
247
271
  .option("--local-registry <auto|false|url>", "local npm registry selection", "auto")
@@ -262,6 +286,7 @@ export function createReleaseCommand(): Command {
262
286
  validate: string[];
263
287
  demoDeploy?: boolean;
264
288
  releaseNotes?: boolean;
289
+ releaseNotesInstructions?: string;
265
290
  },
266
291
  ) => {
267
292
  await runRelease({
@@ -283,6 +308,7 @@ export function createReleaseCommand(): Command {
283
308
  validationTasks: options.validate,
284
309
  demoDeploy: options.demoDeploy,
285
310
  releaseNotes: options.releaseNotes,
311
+ releaseNotesInstructions: options.releaseNotesInstructions,
286
312
  });
287
313
  },
288
314
  );