@dbx-tools/projen 0.6.47 → 0.6.48

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
@@ -111,9 +111,14 @@ schema modules. They are written read-only, and the root ESLint task runs with
111
111
  `--fix` (which fails on a read-only file), so each generated module is added to
112
112
  `ignorePatterns` at synth - named individually via `codegen.codegenModulePaths()`,
113
113
  never as a blanket `<package>/src/**`. A codegen package may hold hand-written
114
- modules beside its generated ones, and those must stay linted. `generateBarrels()` writes package-root `index.ts` barrels with
115
- module namespaces and flat unique type exports, returning the number that
116
- actually changed. A barrel whose export surface is unchanged is left untouched,
114
+ modules beside its generated ones, and those must stay linted.
115
+
116
+ `generateBarrels()` writes package-root `index.ts` barrels with module
117
+ namespaces and flat unique type exports, returning the number that actually
118
+ changed. A name two modules both declare is ambiguous and stays namespace-only —
119
+ except when one of them is generated: the hand-written module is the curated view
120
+ of the generated shape (`shared-genie`'s `genie-model.ts` extends its own
121
+ codegen'd `dashboards.ts`), so it owns the name and stays hoisted. A barrel whose export surface is unchanged is left untouched,
117
122
  read-only bit included, so concurrent writers never collide over it. Every
118
123
  package is attempted even if one fails; the failures are re-thrown together as an
119
124
  `AggregateError` naming each package, rather than the first one abandoning the
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.47",
30
- "@dbx-tools/path": "0.6.47",
31
- "@dbx-tools/shared-core": "0.6.47",
29
+ "@dbx-tools/core": "0.6.48",
30
+ "@dbx-tools/path": "0.6.48",
31
+ "@dbx-tools/shared-core": "0.6.48",
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.47",
50
+ "version": "0.6.48",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/barrels.ts CHANGED
@@ -33,6 +33,15 @@
33
33
  * name stays namespace-only. Names that collide with a generated namespace, or
34
34
  * that a hand-authored `exports.ts` declares, are never hoisted (that file wins).
35
35
  *
36
+ * One collision is NOT ambiguous, though: a HAND-WRITTEN module and a GENERATED
37
+ * one (a codegen `src/` module, recognised by its do-not-edit banner) declaring
38
+ * the same name. The hand-written module is by definition the curated view of the
39
+ * generated shape - `shared-genie`'s `genie-model.ts` extends and re-exports its
40
+ * own generated `dashboards.ts` - so it WINS and its name is still hoisted.
41
+ * Treating that pair as ambiguous is what silently dropped `GenieMessage`,
42
+ * `GenieSpace`, and `MessageStatus` from the barrel the moment the two modules
43
+ * became siblings, breaking every consumer importing them by name.
44
+ *
36
45
  * The result gets a do-not-edit header + read-only bit (see `./generated`).
37
46
  */
38
47
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -40,7 +49,7 @@ import { join, relative } from "node:path";
40
49
  import { find } from "@dbx-tools/path";
41
50
  import { string } from "@dbx-tools/shared-core";
42
51
  import isIdentifier from "is-identifier";
43
- import { header, makeReadonly, makeWritable, type HeaderOpts } from "./generated.ts";
52
+ import { header, isGenerated, makeReadonly, makeWritable, type HeaderOpts } from "./generated.ts";
44
53
  import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports.ts";
45
54
  import { isModuleFile, toPosix, recordedPackages, repoRoot } from "./packages.ts";
46
55
 
@@ -140,8 +149,10 @@ function namespaceLines(content: string): { ns: string; modulePath: string }[] {
140
149
  * package's modules - `export type { ... }` for types, `export { ... }` for
141
150
  * non-function values (classes, consts, enums, …). `export function` names are
142
151
  * never hoisted; they stay namespace-only (`posixPath.toPosix`). A name declared
143
- * by two or more modules is ambiguous and left namespace-only. `suppress` names
144
- * (a hand-authored `exports.ts` surface) are never hoisted so that file stays
152
+ * by two or more modules is ambiguous and left namespace-only - UNLESS exactly one
153
+ * of them is hand-written and the rest are generated, in which case the
154
+ * hand-written module owns the name (see the module doc). `suppress` names (a
155
+ * hand-authored `exports.ts` surface) are never hoisted so that file stays
145
156
  * authoritative.
146
157
  */
147
158
  function hoistUniqueExports(content: string, pkgDir: string, suppress: Set<string>): string {
@@ -157,17 +168,32 @@ function hoistUniqueExports(content: string, pkgDir: string, suppress: Set<strin
157
168
  // Uniqueness is tallied over hoistable types AND values together - name ->
158
169
  // { count, owning module }. Functions are excluded from hoisting and from
159
170
  // this tally so they do not block a same-named type/class in another module.
160
- const seen = new Map<string, { count: number; modulePath: string }>();
171
+ //
172
+ // A generated module never claims a name a hand-written sibling also declares:
173
+ // it is counted only while no hand-written module owns the name, and it yields
174
+ // ownership as soon as one does. So a curated re-export
175
+ // (`genie-model.ts`'s `GenieMessage`, extending generated `dashboards.ts`)
176
+ // stays hoisted, while two HAND-WRITTEN modules claiming one name are still
177
+ // ambiguous and stay namespace-only.
178
+ const seen = new Map<string, { count: number; modulePath: string; generated: boolean }>();
161
179
  const perModule = new Map<string, ModuleExport[]>();
162
180
  for (const { modulePath } of namespaces) {
163
- const exports = moduleExports(join(pkgDir, modulePath.replace(/^\.\//, ""))).filter(
164
- (e) => !e.isFunction,
165
- );
181
+ const file = join(pkgDir, modulePath.replace(/^\.\//, ""));
182
+ const exports = moduleExports(file).filter((e) => !e.isFunction);
166
183
  perModule.set(modulePath, exports);
184
+ const generated = isGenerated(file);
167
185
  for (const { name } of exports) {
168
186
  const prior = seen.get(name);
169
- if (prior) prior.count += 1;
170
- else seen.set(name, { count: 1, modulePath });
187
+ if (!prior) {
188
+ seen.set(name, { count: 1, modulePath, generated });
189
+ continue;
190
+ }
191
+ // Hand-written beats generated, either direction, without counting as a clash.
192
+ if (prior.generated !== generated) {
193
+ if (prior.generated) seen.set(name, { count: 1, modulePath, generated });
194
+ continue;
195
+ }
196
+ prior.count += 1;
171
197
  }
172
198
  }
173
199
 
package/src/generated.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  */
11
11
  import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
12
 
13
+ /** First line of every banner {@link header} builds. */
14
+ const MARKER = "// GENERATED by";
15
+
13
16
  const READONLY = 0o444;
14
17
  const WRITABLE = 0o644;
15
18
 
@@ -46,12 +49,29 @@ export interface HeaderOpts {
46
49
 
47
50
  /** Build the do-not-edit banner (line comments, for TS/JS). */
48
51
  export function header(opts: HeaderOpts): string {
49
- const lines = [`// GENERATED by ${opts.tool} - DO NOT EDIT.`];
52
+ const lines = [`${MARKER} ${opts.tool} - DO NOT EDIT.`];
50
53
  if (opts.source) lines.push(`// Regenerated from ${opts.source}.`);
51
54
  lines.push("// Hand edits are overwritten on the next watch; this file is read-only.");
52
55
  return `${lines.join("\n")}\n`;
53
56
  }
54
57
 
58
+ /**
59
+ * True if the file exists and carries the do-not-edit banner {@link header}
60
+ * writes - i.e. a toolchain wrote it and a hand edit would be overwritten.
61
+ *
62
+ * Prefer this over {@link isReadonly} when the answer must survive a fresh
63
+ * clone: git tracks no read-only bit, so a generated file checked out before the
64
+ * first synth looks hand-authored by mode alone, while the banner is part of the
65
+ * committed content.
66
+ */
67
+ export function isGenerated(file: string): boolean {
68
+ try {
69
+ return readFileSync(file, "utf8").startsWith(MARKER);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
55
75
  /**
56
76
  * Prepend the do-not-edit header to a file some tool just wrote (e.g. a
57
77
  * generated barrel `index.ts`), then set it read-only. Idempotent.
@@ -59,7 +79,7 @@ export function header(opts: HeaderOpts): string {
59
79
  export function stampGenerated(file: string, opts: HeaderOpts): void {
60
80
  makeWritable(file);
61
81
  const body = readFileSync(file, "utf8");
62
- const next = body.startsWith("// GENERATED by") ? body : `${header(opts)}\n${body}`;
82
+ const next = body.startsWith(MARKER) ? body : `${header(opts)}\n${body}`;
63
83
  writeFileSync(file, next);
64
84
  makeReadonly(file);
65
85
  }