@dbx-tools/projen 0.9.154 → 0.9.158

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 (49) hide show
  1. package/README.md +36 -47
  2. package/index.ts +42 -20
  3. package/package.json +8 -8
  4. package/src/_task-command.ts +7 -7
  5. package/src/appkit-workspace.ts +278 -104
  6. package/src/barrels.ts +147 -259
  7. package/src/bun-app.ts +54 -10
  8. package/src/clean.ts +6 -6
  9. package/src/engine-root.ts +4 -4
  10. package/src/mixin.ts +0 -2
  11. package/src/module-exports.ts +4 -5
  12. package/src/npm-packaging.ts +128 -0
  13. package/src/npm-release.ts +200 -0
  14. package/src/packages.ts +11 -11
  15. package/src/project-js.ts +209 -103
  16. package/src/project-predicate.ts +5 -12
  17. package/src/project-py.ts +6 -6
  18. package/src/project.ts +7 -14
  19. package/src/publish.ts +3 -3
  20. package/src/python-node-bindings.ts +16 -12
  21. package/src/python-packaging.ts +177 -0
  22. package/src/release-options.ts +2 -2
  23. package/src/release.ts +3 -3
  24. package/src/shared-core-usage.ts +17 -4
  25. package/src/tags.ts +10 -9
  26. package/src/watch.ts +7 -9
  27. package/src/workspace-lock.ts +2 -2
  28. package/src/workspace-manifest.ts +2 -2
  29. package/tasks/appkit-deploy.ts +35 -15
  30. package/tasks/barrels.ts +6 -9
  31. package/tasks/bump.ts +1 -1
  32. package/tasks/clean.ts +3 -3
  33. package/tasks/cli.ts +3 -3
  34. package/tasks/compile-workspace.ts +8 -8
  35. package/tasks/dev-watch.ts +11 -11
  36. package/tasks/local-publish.ts +6 -6
  37. package/tasks/publish-npm.ts +11 -319
  38. package/tasks/publish-python.ts +2 -180
  39. package/tasks/publish.ts +6 -11
  40. package/tasks/python-node-bindings.ts +2 -2
  41. package/tasks/python-registry.ts +6 -6
  42. package/tasks/python-sync.ts +2 -3
  43. package/tasks/release-version.ts +1 -1
  44. package/tasks/release.ts +11 -11
  45. package/tasks/sync.ts +2 -2
  46. package/tasks/test-workspace.ts +9 -9
  47. package/tasks/verify-publications.ts +9 -5
  48. package/tasks/version-check.ts +5 -5
  49. /package/{tasks → src}/python-release.ts +0 -0
package/src/barrels.ts CHANGED
@@ -1,81 +1,30 @@
1
1
  /**
2
- * Barrel generator.
2
+ * Generate one namespace-only package barrel plus one root-level barrel for
3
+ * every public source folder.
3
4
  *
4
- * For every package it writes the package-root `index.ts` plus generated
5
- * `src/**​/index.ts` subpath barrels when the package enables the generated
6
- * nested export pattern. The root keeps its existing direct module
7
- * namespaces; nested barrels expose each directory as
8
- * `@scope/package/<directory>`. Both follow the same rules (see
9
- * {@link isExcluded}):
10
- * 1. a file/folder whose name starts with `_` is private and never barrelled;
11
- * 2. test / `.d.ts` files are skipped;
12
- * 3. a hand-authored `src/**​/index.ts` is a subpath entry, while a generated
13
- * nested index is the facade for its generated folder;
14
- * 4. only files that actually contain an `export` are re-exported.
5
+ * `src/auth/react/gate.ts` contributes to `index.ts`, `index.auth.ts`, and
6
+ * `index.auth.react.ts`. Package export maps point folder subpaths at those root
7
+ * files. Source folders never contain generated `index.ts` files, and
8
+ * individual modules never receive package subpaths.
15
9
  *
16
- * A hand-authored `exports.ts` sitting next to any generated `index.ts` (a
17
- * Vite-style override) is spliced in last and wins: its exports are appended,
18
- * and any generated `export * as <ns>` whose namespace it also declares is
19
- * dropped so the custom one takes priority. This keeps barrels auto-generated
20
- * while letting you add or override individual exports at every depth.
21
- *
22
- *
23
- * Each eligible module becomes `export * as <name>` from its barrel-relative
24
- * path (camelCase namespace from its path segments; invalid identifiers
25
- * suffixed with `Module`), sorted by module path.
26
- *
27
- * On top of the namespace lines, every export that is UNIQUE across the package
28
- * (declared in exactly one module) is also HOISTED to the barrel's top level, so
29
- * consumers can write `GenieMessage` or `DBXToolsNodeProject` instead of
30
- * `genieModel.GenieMessage` / `project.DBXToolsNodeProject`. Types go out as
31
- * `export type { ... }` (required under `isolatedModules`), values as
32
- * `export { ... }`. The module namespaces stay either way, so a namespaced call
33
- * site keeps working.
34
- *
35
- * The package-root barrel also exports `PACKAGE_IDENTIFIER` and
36
- * `PACKAGE_VERSION` from the package's own `package.json`.
37
- *
38
- * Uniqueness is tallied over types and values TOGETHER: a name carried by two
39
- * modules is ambiguous whichever kind it is, and hoisting one module's value
40
- * beside another's same-named type would emit two conflicting re-exports. Such a
41
- * name stays namespace-only. Names that collide with a generated namespace, or
42
- * that a hand-authored `exports.ts` declares, are never hoisted (that file wins).
43
- *
44
- * One collision is NOT ambiguous, though: a HAND-WRITTEN module and a GENERATED
45
- * one (a codegen `src/` module, recognised by its do-not-edit banner) declaring
46
- * the same name. The hand-written module is by definition the curated view of the
47
- * generated shape - `shared-genie`'s `genie-model.ts` extends and re-exports its
48
- * own generated `dashboards.ts` - so it WINS and its name is still hoisted.
49
- * Treating that pair as ambiguous is what silently dropped `GenieMessage`,
50
- * `GenieSpace`, and `MessageStatus` from the barrel the moment the two modules
51
- * became siblings, breaking every consumer importing them by name.
52
- *
53
- * The result gets a do-not-edit header + read-only bit (see `./generated`).
10
+ * Every output is stamped and read-only.
54
11
  */
55
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
56
13
  import { dirname, join, relative } from "node:path";
57
- import { find } from "@dbx-tools/path";
58
- import { json, stringUtils } from "@dbx-tools/shared-core";
14
+ import { findFilesSync } from "@dbx-tools/path";
15
+ import { parseRecord, tokenizeWithOptions, capitalize, pluralize } from "@dbx-tools/shared-core";
59
16
  import isIdentifier from "is-identifier";
60
17
  import { header, isGenerated, makeReadonly, makeWritable, type HeaderOpts } from "./generated.ts";
61
18
  import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports.ts";
62
19
  import { isModuleFile, toPosix, recordedPackages, resolveRepoRoot } from "./packages.ts";
63
20
 
64
- /**
65
- * A `src`-relative posix path excluded from the root barrel:
66
- * 1. any path segment starting with `_` (private module or folder);
67
- * 2. a test / spec file;
68
- * 3. a `.d.ts` declaration;
69
- * 4. a hand-authored `src/**​/index.ts` - a subpath entry (e.g. `src/react/index.ts`
70
- * behind a package's `./react` export), not a module to namespace into the barrel.
71
- * Generated nested indexes are retained as the generated folder's facade.
72
- */
73
- function isExcluded(relPath: string, srcDir: string): boolean {
21
+ /** Whether a source-relative module is private, test-only, declared, or an index. */
22
+ function isExcluded(relPath: string): boolean {
74
23
  return (
75
24
  /(^|\/)_/.test(relPath) ||
76
25
  /\.(test|spec)\./.test(relPath) ||
77
26
  /\.d\.ts$/.test(relPath) ||
78
- (/(^|\/)index\.ts$/.test(relPath) && !isGenerated(join(srcDir, relPath)))
27
+ /(^|\/)index\.(?:tsx?|jsx?|mts|cts)$/.test(relPath)
79
28
  );
80
29
  }
81
30
 
@@ -110,7 +59,7 @@ const BARREL_HEADER: HeaderOpts = {
110
59
  source: "the exporting modules in ./src",
111
60
  };
112
61
 
113
- /** Generated package metadata exports, reserved against source-module hoisting. */
62
+ /** Generated package metadata exports. */
114
63
  const PACKAGE_IDENTIFIER_EXPORT = "PACKAGE_IDENTIFIER";
115
64
  const PACKAGE_IDENTIFIER_LINE = `export const ${PACKAGE_IDENTIFIER_EXPORT} = "";`;
116
65
  const PACKAGE_IDENTIFIER_LINE_RE = /^export const PACKAGE_IDENTIFIER = .*;$/m;
@@ -120,9 +69,7 @@ const PACKAGE_VERSION_LINE_RE = /^export const PACKAGE_VERSION = .*;$/m;
120
69
 
121
70
  /** `config-tools` / `config_tools` -> `configTools`; `local-fs` -> `localFS`. */
122
71
  function moduleSegmentToCamel(segment: string): string {
123
- const tokens = [
124
- ...stringUtils.tokenizeWithOptions({ lowerCase: true, capitalize: true }, segment),
125
- ];
72
+ const tokens = [...tokenizeWithOptions({ lowerCase: true, capitalize: true }, segment)];
126
73
  if (tokens.length === 0) return segment;
127
74
  const first = tokens[0]!;
128
75
  const rest = tokens.slice(1);
@@ -151,7 +98,7 @@ function modulePathToNamespace(modulePath: string): string {
151
98
  : names[0]! +
152
99
  names
153
100
  .slice(1)
154
- .map((s) => stringUtils.capitalize(s))
101
+ .map((s) => capitalize(s))
155
102
  .join("");
156
103
  if (!isIdentifier(name)) {
157
104
  name = `${name}Module`;
@@ -159,7 +106,7 @@ function modulePathToNamespace(modulePath: string): string {
159
106
  return name;
160
107
  }
161
108
 
162
- /** A relative module path parsed out of a generated `export * as <ns>` line. */
109
+ /** Relative modules parsed from generated namespace exports. */
163
110
  function namespaceLines(content: string): { ns: string; modulePath: string }[] {
164
111
  const out: { ns: string; modulePath: string }[] = [];
165
112
  for (const line of content.split("\n")) {
@@ -169,42 +116,24 @@ function namespaceLines(content: string): { ns: string; modulePath: string }[] {
169
116
  return out;
170
117
  }
171
118
 
172
- /**
173
- * Append hoisted top-level re-exports for every export that is UNIQUE across the
174
- * package's modules - `export type { ... }` for types, `export { ... }` for
175
- * non-function values (classes, consts, enums, …). `export function` names are
176
- * never hoisted; they stay namespace-only (`posixPath.toPosix`). A name declared
177
- * by two or more modules is ambiguous and left namespace-only - UNLESS exactly one
178
- * of them is hand-written and the rest are generated, in which case the
179
- * hand-written module owns the name (see the module doc). `suppress` names (a
180
- * hand-authored `exports.ts` surface) are never hoisted so that file stays
181
- * authoritative.
182
- */
183
- function hoistUniqueExports(content: string, barrelDir: string, suppress: Set<string>): string {
119
+ /** Append flat exports for names owned by exactly one module. */
120
+ function hoistUniqueExports(
121
+ content: string,
122
+ barrelDir: string,
123
+ modulePaths: readonly string[],
124
+ reserved: Set<string>,
125
+ ): string {
184
126
  const namespaces = namespaceLines(content);
185
- if (namespaces.length === 0) return content;
186
127
 
187
- // A hoisted name must never collide with a generated `export * as <ns>`
188
- // namespace (e.g. a `mixin.ts` exporting a `mixin` value alongside the
189
- // `export * as mixin` line), so treat every namespace id as suppressed too.
190
- const blocked = new Set<string>(suppress);
128
+ const blocked = new Set(reserved);
191
129
  for (const { ns } of namespaces) blocked.add(ns);
192
130
 
193
- // Uniqueness is tallied over hoistable types AND values together - name ->
194
- // { count, owning module }. Functions are excluded from hoisting and from
195
- // this tally so they do not block a same-named type/class in another module.
196
- //
197
- // A generated module never claims a name a hand-written sibling also declares:
198
- // it is counted only while no hand-written module owns the name, and it yields
199
- // ownership as soon as one does. So a curated re-export
200
- // (`genie-model.ts`'s `GenieMessage`, extending generated `dashboards.ts`)
201
- // stays hoisted, while two HAND-WRITTEN modules claiming one name are still
202
- // ambiguous and stay namespace-only.
203
131
  const seen = new Map<string, { count: number; modulePath: string; generated: boolean }>();
204
132
  const perModule = new Map<string, ModuleExport[]>();
205
- for (const { modulePath } of namespaces) {
133
+ for (const modulePath of modulePaths) {
134
+ if (!modulePath.startsWith("./src/")) continue;
206
135
  const file = join(barrelDir, modulePath.replace(/^\.\//, ""));
207
- const exports = moduleExports(file).filter((e) => !e.isFunction);
136
+ const exports = moduleExports(file);
208
137
  perModule.set(modulePath, exports);
209
138
  const generated = isGenerated(file);
210
139
  for (const { name } of exports) {
@@ -213,7 +142,6 @@ function hoistUniqueExports(content: string, barrelDir: string, suppress: Set<st
213
142
  seen.set(name, { count: 1, modulePath, generated });
214
143
  continue;
215
144
  }
216
- // Hand-written beats generated, either direction, without counting as a clash.
217
145
  if (prior.generated !== generated) {
218
146
  if (prior.generated) seen.set(name, { count: 1, modulePath, generated });
219
147
  continue;
@@ -223,13 +151,12 @@ function hoistUniqueExports(content: string, barrelDir: string, suppress: Set<st
223
151
  }
224
152
 
225
153
  const lines: string[] = [];
226
- for (const { modulePath } of namespaces) {
154
+ for (const modulePath of modulePaths) {
227
155
  const types: string[] = [];
228
156
  const values: string[] = [];
229
157
  for (const { name, isType } of perModule.get(modulePath) ?? []) {
230
158
  if (blocked.has(name)) continue;
231
159
  const entry = seen.get(name);
232
- // Unique across the package AND this is the module that owns it.
233
160
  if (!entry || entry.count !== 1 || entry.modulePath !== modulePath) continue;
234
161
  (isType ? types : values).push(name);
235
162
  }
@@ -237,55 +164,7 @@ function hoistUniqueExports(content: string, barrelDir: string, suppress: Set<st
237
164
  if (types.length) lines.push(`export type { ${types.join(", ")} } from "${modulePath}";`);
238
165
  }
239
166
  if (lines.length === 0) return content;
240
- return `${content.replace(/\n+$/, "")}\n${lines.join("\n")}\n`;
241
- }
242
-
243
- /** Hand-authored override barrel: a sibling of the generated `index.ts`. */
244
- const CUSTOM_EXPORTS_FILE = "exports.ts";
245
-
246
- /**
247
- * Best-effort set of the top-level export names a module declares - named
248
- * declarations, `export { x }` specifiers, `export * as ns`, and default. A bare
249
- * `export *` re-exports opaque names that can't be resolved statically, so a custom
250
- * `exports.ts` should name what it means to override explicitly.
251
- */
252
- function customExportNames(file: string): Set<string> {
253
- const names = new Set<string>();
254
- const body = moduleStatements(file) as ReadonlyArray<Record<string, any>>;
255
- const add = (node: Record<string, any> | undefined | null): void => {
256
- if (node && typeof node.name === "string") names.add(node.name);
257
- else if (node && typeof node.value === "string") names.add(node.value);
258
- };
259
- for (const stmt of body) {
260
- if (stmt.type === "ExportDefaultDeclaration") {
261
- names.add("default");
262
- } else if (stmt.type === "ExportAllDeclaration") {
263
- add(stmt.exported); // `export * as ns from ...`; a bare `export *` has none
264
- } else if (stmt.type === "ExportNamedDeclaration") {
265
- for (const spec of stmt.specifiers ?? []) add(spec.exported);
266
- const decl = stmt.declaration;
267
- if (decl?.id) add(decl.id);
268
- for (const d of decl?.declarations ?? []) if (d.id?.type === "Identifier") add(d.id);
269
- }
270
- }
271
- return names;
272
- }
273
-
274
- /**
275
- * Splice a hand-authored `<pkg>/exports.ts` into the barrel. Any generated
276
- * `export * as <ns>` whose namespace the custom file also declares is dropped (so the
277
- * custom export wins - a plain `export *` cannot otherwise override an explicit
278
- * `export * as`), then the whole module is re-exported last.
279
- */
280
- function mergeCustomExports(content: string, barrelDir: string): string {
281
- const customPath = join(barrelDir, CUSTOM_EXPORTS_FILE);
282
- if (!existsSync(customPath)) return content;
283
- const overridden = customExportNames(customPath);
284
- const kept = content.split("\n").filter((line) => {
285
- const ns = /^export \* as (\w+) from /.exec(line)?.[1];
286
- return !(ns && overridden.has(ns));
287
- });
288
- return `${kept.join("\n").replace(/\n+$/, "")}\nexport * from "./exports.ts";\n`;
167
+ return `${content.replace(/\n+$/, "")}\n${lines.join("\n")}`;
289
168
  }
290
169
 
291
170
  /** Read the authoritative npm package metadata emitted by the package project. */
@@ -295,7 +174,7 @@ function packageMetadata(pkgDir: string): {
295
174
  } {
296
175
  const manifestPath = join(pkgDir, "package.json");
297
176
  const manifest = existsSync(manifestPath)
298
- ? json.parseRecord(readFileSync(manifestPath, "utf8"))
177
+ ? parseRecord(readFileSync(manifestPath, "utf8"))
299
178
  : undefined;
300
179
  const name = manifest?.name;
301
180
  if (typeof name !== "string" || !name.trim()) {
@@ -337,148 +216,157 @@ export function isGeneratedBarrel(file: string): boolean {
337
216
  return existsSync(file) && readFileSync(file, "utf8").startsWith(header(BARREL_HEADER));
338
217
  }
339
218
 
340
- /** Public direct modules and child-directory facades for one generated barrel. */
341
- function barrelCandidates(barrelDir: string): string[] {
342
- const files = [...find.findFilesSync("*", { cwd: barrelDir })]
343
- .map(toPosix)
344
- .filter((file) => !file.includes("/"))
345
- .filter((file) => file !== "index.ts" && file !== CUSTOM_EXPORTS_FILE)
346
- .filter((file) => isModuleFile(file))
347
- .filter((file) => !isExcluded(file, barrelDir))
348
- .filter((file) => hasExport(join(barrelDir, file)));
349
- const childIndexes = [...find.findFilesSync("*/index.ts", { cwd: barrelDir })]
350
- .map(toPosix)
351
- .filter((file) => file.split("/").length === 2)
352
- .filter((file) => !isExcluded(file, barrelDir))
353
- .filter((file) => hasExport(join(barrelDir, file)));
354
- return [...files, ...childIndexes];
219
+ interface BarrelModule {
220
+ readonly specifier: string;
221
+ readonly namespacePath: string;
222
+ readonly reexport: boolean;
223
+ }
224
+
225
+ /** Root-level barrel filename for one source-relative folder. */
226
+ export function folderBarrelFile(directory: string): string {
227
+ return `index.${toPosix(directory).replaceAll("/", ".")}.ts`;
355
228
  }
356
229
 
357
230
  /** Write or remove one generated barrel and return whether its file changed. */
358
231
  function generateBarrel(
359
232
  pkgDir: string,
360
- barrelDir: string,
361
- moduleFiles: string[],
233
+ barrel: string,
234
+ modules: readonly BarrelModule[],
362
235
  includePackageMetadata: boolean,
363
236
  ): number {
364
- const barrel = includePackageMetadata ? join(pkgDir, "index.ts") : join(barrelDir, "index.ts");
365
- if (!includePackageMetadata && existsSync(barrel) && !isGeneratedBarrel(barrel)) return 0;
366
- const before = existsSync(barrel) ? readFileSync(barrel, "utf8") : undefined;
367
-
368
- const byModulePath = new Map<string, string>();
369
- for (const file of moduleFiles) {
370
- const stem = file.replace(/(^|\/)index\.ts$/, "").replace(MODULE_EXT_RE, "");
371
- const existing = byModulePath.get(stem);
372
- if (!existing || (!isSourceExt(existing) && isSourceExt(file))) byModulePath.set(stem, file);
237
+ const beforeStat = statSync(barrel, { throwIfNoEntry: false });
238
+ if (beforeStat && !isGeneratedBarrel(barrel)) {
239
+ throw new Error(`Generated barrel path is owned by a hand-written file: ${barrel}`);
240
+ }
241
+ const before = beforeStat ? readFileSync(barrel, "utf8") : undefined;
242
+
243
+ const bySourceStem = new Map<string, BarrelModule>();
244
+ for (const module of modules) {
245
+ const stem = module.specifier.replace(MODULE_EXT_RE, "");
246
+ const existing = bySourceStem.get(stem);
247
+ if (!existing || (!isSourceExt(existing.specifier) && isSourceExt(module.specifier))) {
248
+ bySourceStem.set(stem, module);
249
+ }
373
250
  }
374
- const modulePaths = [...byModulePath.keys()].sort((left, right) => left.localeCompare(right));
251
+ const selected = [...bySourceStem.values()].sort((left, right) =>
252
+ left.specifier.localeCompare(right.specifier),
253
+ );
375
254
 
376
- if (modulePaths.length === 0) {
377
- if (before !== undefined && (includePackageMetadata || isGeneratedBarrel(barrel))) {
255
+ if (selected.length === 0) {
256
+ if (!includePackageMetadata && before !== undefined) {
378
257
  makeWritable(barrel);
379
258
  rmSync(barrel, { force: true });
380
- return includePackageMetadata ? 0 : 1;
259
+ return 1;
381
260
  }
382
- return 0;
261
+ if (!includePackageMetadata) return 0;
383
262
  }
384
263
 
385
- const namespaceExports = modulePaths
386
- .map((stem) => {
387
- const prefix = includePackageMetadata ? "./src/" : "./";
388
- const modulePath = `${prefix}${byModulePath.get(stem)!}`;
389
- return `export * as ${modulePathToNamespace(modulePath)} from "${modulePath}";`;
390
- })
391
- .join("\n");
264
+ const exportedNames = new Set(
265
+ selected.flatMap(({ specifier }) =>
266
+ moduleExports(join(pkgDir, specifier.replace(/^\.\//, ""))).map(({ name }) => name),
267
+ ),
268
+ );
269
+ const namespaceExports = selected.flatMap(({ specifier, namespacePath, reexport }) => {
270
+ const namespace = modulePathToNamespace(`./${namespacePath}`);
271
+ return [
272
+ ...(exportedNames.has(namespace) ? [] : [`export * as ${namespace} from "${specifier}";`]),
273
+ ...(reexport ? [`export * from "${specifier}";`] : []),
274
+ ];
275
+ });
392
276
  let content = includePackageMetadata
393
- ? `${PACKAGE_IDENTIFIER_LINE}\n${PACKAGE_VERSION_LINE}\n${namespaceExports}`
394
- : namespaceExports;
395
- const customDir = includePackageMetadata ? pkgDir : barrelDir;
396
- const customPath = join(customDir, CUSTOM_EXPORTS_FILE);
397
- const suppress = existsSync(customPath) ? customExportNames(customPath) : new Set<string>();
398
- if (includePackageMetadata) {
399
- suppress.add(PACKAGE_IDENTIFIER_EXPORT);
400
- suppress.add(PACKAGE_VERSION_EXPORT);
401
- }
402
- content = hoistUniqueExports(content, includePackageMetadata ? pkgDir : barrelDir, suppress);
403
- content = mergeCustomExports(content, customDir);
404
- content = `${content.replace(/\n+$/, "")}\n`;
405
- const template = `${header(BARREL_HEADER)}\n${content}`;
277
+ ? `${PACKAGE_IDENTIFIER_LINE}\n${PACKAGE_VERSION_LINE}\n${namespaceExports.join("\n")}`
278
+ : namespaceExports.join("\n");
279
+ content = hoistUniqueExports(
280
+ content,
281
+ pkgDir,
282
+ selected.map(({ specifier }) => specifier),
283
+ new Set(includePackageMetadata ? [PACKAGE_IDENTIFIER_EXPORT, PACKAGE_VERSION_EXPORT] : []),
284
+ );
285
+ const template = `${header(BARREL_HEADER)}\n${content.replace(/\n+$/, "")}\n`;
406
286
  const next = includePackageMetadata ? withPackageMetadata(template, pkgDir) : template;
407
287
  const structurallySame = includePackageMetadata
408
288
  ? before !== undefined && withoutPackageMetadata(before) === template && before === next
409
289
  : before === next;
410
- if (structurallySame) return 0;
290
+ if (structurallySame) {
291
+ if (beforeStat && (beforeStat.mode & 0o777) !== 0o444) makeReadonly(barrel);
292
+ return 0;
293
+ }
411
294
 
412
295
  writeBarrel(barrel, next);
413
296
  makeReadonly(barrel);
414
297
  return 1;
415
298
  }
416
299
 
417
- /**
418
- * Rebuild one package's nested and root barrels. Nested directories are processed
419
- * deepest-first so a parent can export each child directory through its generated
420
- * `index.ts`. The package root deliberately continues to export the underlying
421
- * modules directly, preserving its existing namespaces while adding subpath entrypoints.
422
- */
300
+ function removeGeneratedBarrel(file: string): number {
301
+ if (!isGeneratedBarrel(file)) return 0;
302
+ makeWritable(file);
303
+ rmSync(file, { force: true });
304
+ return 1;
305
+ }
306
+
307
+ /** Rebuild one package's root and folder barrels. */
423
308
  function generateForPackage(pkgDir: string): number {
424
309
  const srcDir = join(pkgDir, "src");
425
- if (!existsSync(srcDir)) return 0;
426
-
427
- const sourceFiles = [...find.findFilesSync("**/*", { cwd: srcDir })]
428
- .map(toPosix)
429
- .filter((file) => isModuleFile(file) || /(^|\/)index\.ts$/.test(file));
430
- const manifest = json.parseRecord(readFileSync(join(pkgDir, "package.json"), "utf8"));
431
- const exports = manifest?.exports;
432
- const nestedExportsEnabled =
433
- exports != null &&
434
- typeof exports === "object" &&
435
- !Array.isArray(exports) &&
436
- (exports as Record<string, unknown>)["./*"] === "./src/*/index.ts";
310
+ if (!existsSync(srcDir)) {
311
+ return generateBarrel(pkgDir, join(pkgDir, "index.ts"), [], true);
312
+ }
313
+
314
+ const discoveredFiles = [...findFilesSync("**/*", { cwd: srcDir })].map(toPosix);
315
+ const nestedIndexes = discoveredFiles.filter((file) =>
316
+ /(^|\/)index\.(?:tsx?|jsx?|mts|cts)$/.test(file),
317
+ );
318
+ for (const index of nestedIndexes) {
319
+ const path = join(srcDir, index);
320
+ if (!isGeneratedBarrel(path)) {
321
+ throw new Error(`Nested source index is unsupported: ${path}`);
322
+ }
323
+ }
324
+
325
+ let changed = nestedIndexes.reduce(
326
+ (count, index) => count + removeGeneratedBarrel(join(srcDir, index)),
327
+ 0,
328
+ );
329
+ const sourceFiles = discoveredFiles
330
+ .filter((file) => isModuleFile(file))
331
+ .filter((file) => !isExcluded(file))
332
+ .filter((file) => hasExport(join(srcDir, file)));
437
333
  const directories = new Set<string>();
438
334
  for (const file of sourceFiles) {
439
335
  const fileDir = dirname(file);
440
336
  if (fileDir === "." || /(^|\/)_[^/]+/.test(fileDir)) continue;
441
- let current = fileDir;
442
- while (current !== ".") {
443
- directories.add(current);
444
- current = dirname(current);
445
- }
337
+ directories.add(fileDir);
446
338
  }
447
339
 
448
- let changed = 0;
449
- const nestedDirectories = [...directories].sort((left, right) => {
450
- const depth = (value: string): number => value.split("/").length;
451
- return depth(right) - depth(left) || right.localeCompare(left);
452
- });
453
- for (const directory of nestedDirectories) {
454
- const barrelDir = join(srcDir, directory);
455
- if (nestedExportsEnabled) {
456
- changed += generateBarrel(pkgDir, barrelDir, barrelCandidates(barrelDir), false);
457
- } else {
458
- const barrel = join(barrelDir, "index.ts");
459
- if (isGeneratedBarrel(barrel)) {
460
- makeWritable(barrel);
461
- rmSync(barrel, { force: true });
462
- changed += 1;
463
- }
340
+ const folderBarrels = new Set([...directories].map(folderBarrelFile));
341
+ for (const file of [...findFilesSync("index.*.ts", { cwd: pkgDir })].map(toPosix)) {
342
+ if (file.includes("/") || file.endsWith(".d.ts") || folderBarrels.has(file)) continue;
343
+ const path = join(pkgDir, file);
344
+ if (!isGeneratedBarrel(path)) {
345
+ throw new Error(`Folder barrel path is owned by a hand-written file: ${path}`);
464
346
  }
347
+ changed += removeGeneratedBarrel(path);
465
348
  }
466
349
 
467
- const candidates = sourceFiles
468
- .filter((file) => !isGeneratedBarrel(join(srcDir, file)))
469
- .filter((file) => !isExcluded(file, srcDir))
470
- .filter((file) => hasExport(join(srcDir, file)));
471
- const generatedIndexDirs = new Set(
472
- candidates
473
- .filter((file) => /(^|\/)index\.ts$/.test(file) && isGenerated(join(srcDir, file)))
474
- .map((file) => file.replace(/(^|\/)index\.ts$/, "")),
475
- );
476
- const publicCandidates = candidates.filter((file) => {
477
- if (/(^|\/)index\.ts$/.test(file)) return true;
478
- return ![...generatedIndexDirs].some((dir) => dir && file.startsWith(`${dir}/`));
479
- });
480
-
481
- return changed + generateBarrel(pkgDir, srcDir, publicCandidates, true);
350
+ const rootModules = sourceFiles
351
+ .filter((source) => dirname(source) === ".")
352
+ .map((source) => ({
353
+ specifier: `./src/${source}`,
354
+ namespacePath: source,
355
+ reexport: moduleExports(join(srcDir, source)).length === 0,
356
+ }));
357
+ changed += generateBarrel(pkgDir, join(pkgDir, "index.ts"), rootModules, true);
358
+
359
+ for (const directory of [...directories].sort((left, right) => left.localeCompare(right))) {
360
+ const modules: BarrelModule[] = sourceFiles
361
+ .filter((source) => dirname(source) === directory)
362
+ .map((source) => ({
363
+ specifier: `./src/${source}`,
364
+ namespacePath: source.slice(directory.length + 1),
365
+ reexport: moduleExports(join(srcDir, source)).length === 0,
366
+ }));
367
+ changed += generateBarrel(pkgDir, join(pkgDir, folderBarrelFile(directory)), modules, false);
368
+ }
369
+ return changed;
482
370
  }
483
371
 
484
372
  /**
@@ -549,7 +437,7 @@ export function generateBarrels(
549
437
  const names = failures.map((f) => relative(projectRoot, f.dir) || f.dir);
550
438
  throw new AggregateError(
551
439
  failures.map((f) => f.err),
552
- `${stringUtils.pluralize(failures.length, "barrel")} failed: ${names.join(", ")}`,
440
+ `${pluralize(failures.length, "barrel")} failed: ${names.join(", ")}`,
553
441
  );
554
442
  }
555
443
  return total;