@dbx-tools/projen 0.1.1 → 0.3.43

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
@@ -3,7 +3,7 @@
3
3
  Projen engine for dbx-tools pnpm workspaces.
4
4
 
5
5
  Import this package from `.projenrc.ts` when a repository should discover
6
- workspace packages from the filesystem and generate manifests, tsconfigs,
6
+ packages from the filesystem and generate manifests, tsconfigs,
7
7
  barrels, OpenAPI clients, codegen outputs, and release tasks.
8
8
 
9
9
  Key features:
@@ -28,7 +28,7 @@ import { project as projectApi } from "@dbx-tools/projen";
28
28
  const project = new projectApi.DBXToolsNodeProject({
29
29
  name: "my-apps",
30
30
  scope: "my-apps",
31
- workspacePackageRoots: ["workspaces", "examples"],
31
+ packageRoots: ["packages", "examples"],
32
32
  });
33
33
 
34
34
  project.synth();
@@ -44,19 +44,28 @@ new package membership.
44
44
  ## Customize Packages With Mixins
45
45
 
46
46
  ```ts
47
- import { mixin, project as projectApi, projectPredicate } from "@dbx-tools/projen";
47
+ import { project as projectApi } from "@dbx-tools/projen";
48
48
 
49
49
  const project = new projectApi.DBXToolsNodeProject();
50
50
 
51
- project.with(
52
- mixin.mixin(projectPredicate.hasTag("shared"), (pkg) => {
53
- pkg.addDeps("zod@catalog:");
54
- }),
55
- );
51
+ projectApi.applyToProjects(project, { tags: "shared" }, (pkg) => {
52
+ pkg.addDeps("zod@catalog:");
53
+ });
56
54
 
57
55
  project.synth();
58
56
  ```
59
57
 
58
+ `applyToProjects` AND-s its globs (prefix a glob with `!` to negate) into one
59
+ predicate over the DBXTools child packages, then applies it as a `constructs`
60
+ mixin across the subtree. Filter on the folder (`path`), the tags (`tags`), or
61
+ the name from whichever angle fits: `name` matches the raw projen name verbatim,
62
+ while `identifierPackageName`, `identifierScope`, and `identifierName` match the
63
+ parsed `@scope/name`, its scope, and its unscoped half. Two flags widen the
64
+ selection past DBXTools children - `includeRoots` for the tree root and
65
+ `includeNonDBXToolsProjects` for plain projen projects (which widens the callback
66
+ parameter to `Project`). Drop to `mixin.create(predicate, fn)` +
67
+ `project.with(...)` only when you need a predicate the filters cannot express.
68
+
60
69
  Built-in tag mixins set runtime defaults for `shared`, `node`, `cli`, `server`,
61
70
  `ui`, and `openapi`. Repo-specific mixins layer package-specific dependencies,
62
71
  scripts, and generated files on top.
@@ -64,13 +73,13 @@ scripts, and generated files on top.
64
73
  ## Work With Package Discovery
65
74
 
66
75
  ```ts
67
- import { workspace } from "@dbx-tools/projen";
76
+ import { packages } from "@dbx-tools/projen";
68
77
 
69
- const discovered = workspace.scanPackages(process.cwd(), ["workspaces"]);
70
- const recorded = workspace.workspacePackages();
78
+ const discovered = packages.scanPackages(process.cwd(), ["packages"]);
79
+ const recorded = packages.recordedPackages();
71
80
  ```
72
81
 
73
- `scanPackages()` reads the filesystem during synth. `workspacePackages()` reads
82
+ `scanPackages()` reads the filesystem during synth. `recordedPackages()` reads
74
83
  the generated `pnpm-workspace.yaml` plus package manifests for post-synth tools.
75
84
  Use the latter for docs, linting, and release checks that should match the
76
85
  recorded workspace.
@@ -106,8 +115,25 @@ project.pnpmWorkspace?.addCatalog("react", "^19");
106
115
  project.pnpmWorkspace?.allowBuild("esbuild");
107
116
  ```
108
117
 
109
- `pnpmWorkspace.DBXToolsPNPMWorkspace` owns `pnpm-workspace.yaml`, package
110
- members, catalog entries, overrides, and build-script allowlists.
118
+ projen's native `javascript.PnpmWorkspaceYaml` writes `pnpm-workspace.yaml`;
119
+ `pnpmWorkspace.PnpmWorkspaceState` supplies the options it renders and tracks
120
+ package members, catalog entries, and build-script allowances. Any other pnpm
121
+ setting goes through the root's `workspaceYaml` option, which is projen's typed
122
+ `PnpmWorkspaceYamlOptions`:
123
+
124
+ ```ts
125
+ new DBXToolsNodeProject({ workspaceYaml: { overrides: { glob: "^13.0.0" } } });
126
+ ```
127
+
128
+ `allowBuild` writes pnpm's `allowBuilds` map rather than projen's own
129
+ `allowScripts` option, which for pnpm renders `onlyBuiltDependencies` - a key
130
+ current pnpm does not read, so the list would leave every build script skipped.
131
+ Only allowances are declared; a dependency that is never allowed needs no entry,
132
+ because pnpm warns and moves on.
133
+
134
+ The engine also applies `catalogMode: manual` (keeps `pnpm add` out of the
135
+ generated catalog) and `verifyDepsBeforeRun: warn`. The file is emitted for the
136
+ tree ROOT only; a member package never gets a nested one.
111
137
 
112
138
  ## Clean And Watch Generated Files
113
139
 
@@ -131,7 +157,7 @@ file contract as the CLI.
131
157
  - `mixin` / `projectPredicate` - constructs mixin factory and package
132
158
  predicates.
133
159
  - `tags` - built-in runtime tag mixins and compiler floors.
134
- - `workspace` - filesystem discovery and recorded package metadata.
160
+ - `packages` - filesystem discovery and recorded package metadata.
135
161
  - `pnpmWorkspace` - generated pnpm workspace file and catalog model.
136
162
  - `barrels` / `moduleExports` - public entrypoint generation.
137
163
  - `codegen` - `.d.ts` to zod schema generation.
@@ -142,4 +168,7 @@ file contract as the CLI.
142
168
  - `publish` - packaging and tag-based release helpers.
143
169
  - `engineRoot` - engine package root resolution for bootstrapped repos.
144
170
 
145
- The user-facing CLI is [`dbx-tools`](../../cli/dbx-tools).
171
+ The engine registers its commands as projen tasks on the workspace root, so run
172
+ them with `pnpm run <task>` - `sync` (add `--watch`), `barrels`, `openapi`, and
173
+ `clean`. [`@dbx-tools/cli`](../packages/cli/dbx-tools) is only needed to
174
+ bootstrap a folder that has no `.projenrc.ts` or toolchain yet.
package/index.ts CHANGED
@@ -1,7 +1,10 @@
1
- // Public API of @dbx-tools/projen: the projen engine (project classes, tags,
2
- // mixins, predicates, and the generator utilities). Hand-authored barrel.
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
3
5
  export * as barrels from "./src/barrels";
4
6
  export * as clean from "./src/clean";
7
+ export * as cliBin from "./src/cli-bin";
5
8
  export * as codegen from "./src/codegen";
6
9
  export * as dbxToolsConfig from "./src/dbx-tools-config";
7
10
  export * as engineRoot from "./src/engine-root";
@@ -9,26 +12,45 @@ export * as generated from "./src/generated";
9
12
  export * as mixin from "./src/mixin";
10
13
  export * as moduleExports from "./src/module-exports";
11
14
  export * as openapi from "./src/openapi";
15
+ export * as packages from "./src/packages";
12
16
  export * as pnpmWorkspace from "./src/pnpm-workspace";
13
17
  export * as project from "./src/project";
14
18
  export * as projectPredicate from "./src/project-predicate";
15
- export * as publish from "./src/publish";
19
+ export * as release from "./src/release";
16
20
  export * as scaffold from "./src/scaffold";
17
21
  export * as tags from "./src/tags";
18
22
  export * as tsconfig from "./src/tsconfig";
19
23
  export * as vite from "./src/vite";
20
24
  export * as vscode from "./src/vscode";
21
25
  export * as watch from "./src/watch";
22
- export * as workspace from "./src/workspace";
23
- export type { BarrelModifier } from "./src/barrels";
26
+ export { generateBarrels } from "./src/barrels";
27
+ export { listGeneratedFiles, listNodeModulesDirs, removePaths } from "./src/clean";
28
+ export { CliBinLauncher, addCliBinLaunchers } from "./src/cli-bin";
29
+ export { generateCodegen } from "./src/codegen";
30
+ export { DBXToolsConfig } from "./src/dbx-tools-config";
24
31
  export type { DBXToolsConfigOptions } from "./src/dbx-tools-config";
32
+ export { resolvePkgRoot } from "./src/engine-root";
33
+ export { makeWritable, makeReadonly, isReadonly, header, stampGenerated } from "./src/generated";
25
34
  export type { HeaderOpts } from "./src/generated";
35
+ export { create } from "./src/mixin";
26
36
  export type { ConstructsMixin } from "./src/mixin";
27
- export type { ModuleExport } from "./src/module-exports";
28
- export type { Catalog, PnpmWorkspaceConfig, DBXToolsPNPMWorkspaceOptions } from "./src/pnpm-workspace";
29
- export type { DBXToolsProject, DBXToolsProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project";
30
- export type { BumpLevel } from "./src/publish";
31
- export type { WorkspaceTag } from "./src/tags";
32
- export type { ViteConfigFileOptions } from "./src/vite";
37
+ export { moduleStatements } from "./src/module-exports";
38
+ export type { ModuleStatement, ModuleExport } from "./src/module-exports";
39
+ export { isTsoaController, generateOpenapi } from "./src/openapi";
40
+ export { repoRoot, DEFAULT_PACKAGE_ROOTS, projectName, toPosix, isGeneratedFile, isModuleFile, DiscoveredPackage, scanPackages, readPackageManifest, syncResynthPaths, recordedPackages, recordedRoots } from "./src/packages";
41
+ export type { RecordedPackage } from "./src/packages";
42
+ export { PnpmWorkspaceState } from "./src/pnpm-workspace";
43
+ export type { Catalog, AllowBuilds, DBXToolsPNPMWorkspaceOptions } from "./src/pnpm-workspace";
44
+ export { PackageIdentifier, identifier, applyCompilerOptions, applyIncludes, applyTasks, applyExports, addExports, addPackageFiles, srcModuleExports, PROJEN_VERSION, DBXToolsNodeProject, DBXToolsTypeScriptProject, taskScript, applyToProjects } from "./src/project";
45
+ export type { DBXToolsProject, DBXToolsProjectOptions, DBXToolsTypeScriptProjectOptions, ApplyToProjectsOptions } from "./src/project";
46
+ export { isProject, isDBXToolsProject, hasName, hasIdentifierPackageName, hasIdentifierName, hasIdentifierScope, hasTag, hasPath } from "./src/project-predicate";
47
+ export { DBXToolsRelease } from "./src/release";
48
+ export type { StandaloneRelease, DBXToolsReleaseOptions } from "./src/release";
49
+ export { runSynth } from "./src/scaffold";
50
+ export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags";
51
+ export type { PackageTag } from "./src/tags";
52
+ export { DBXToolsRootTsconfig } from "./src/tsconfig";
53
+ export { ViteConfigFile } from "./src/vite";
54
+ export { DBXToolsVsCode } from "./src/vscode";
55
+ export { watchRoots, watchLoop } from "./src/watch";
33
56
  export type { IgnoreGroupOptions } from "./src/watch";
34
- export type { WorkspacePackage } from "./src/workspace";
package/package.json CHANGED
@@ -2,28 +2,24 @@
2
2
  "name": "@dbx-tools/projen",
3
3
  "devDependencies": {
4
4
  "@types/node": "^24.6.0",
5
- "commit-and-tag-version": "^12",
6
5
  "constructs": "^10.0.0",
7
6
  "projen": "^0.101.6",
8
- "ts-node": "^10.9.2",
7
+ "tsoa": "^6.6.0",
9
8
  "typescript": "^5.9.3"
10
9
  },
11
10
  "dependencies": {
12
11
  "@clack/prompts": "^1.7.0",
13
- "@dbx-tools/core": "^0.0.0",
14
- "@dbx-tools/path": "^0.0.0",
15
- "@dbx-tools/shared-core": "^0.0.0",
16
- "@typescript-eslint/typescript-estree": "^8",
12
+ "@dbx-tools/core": "^0.3.40",
13
+ "@dbx-tools/path": "^0.3.40",
14
+ "@dbx-tools/shared-core": "^0.3.40",
17
15
  "commander": "^15.0.0",
18
- "consola": "^3.4.2",
16
+ "concurrently": "^10.0.3",
19
17
  "constructs": "^10.6.0",
20
18
  "is-identifier": "^1",
21
19
  "openapi-typescript": "^7.13.0",
22
20
  "oxc-parser": "^0.90.0",
23
- "p-memoize": "^8.0.0",
24
- "projen": "^0.101.6",
21
+ "projen": "^0.101.16",
25
22
  "ts-to-zod": "^5.1.0",
26
- "tsoa": "^6.6.0",
27
23
  "tsx": "^4.23.0",
28
24
  "yaml": "^2.9.0"
29
25
  },
@@ -36,15 +32,19 @@
36
32
  },
37
33
  "main": "index.ts",
38
34
  "license": "Apache-2.0",
39
- "version": "0.1.1",
35
+ "version": "0.3.43",
40
36
  "types": "index.ts",
41
37
  "type": "module",
42
38
  "exports": {
43
39
  ".": "./index.ts",
44
- "./engine-root": "./src/engine-root.ts",
45
40
  "./package.json": "./package.json"
46
41
  },
47
- "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"pnpm exec projen\".",
42
+ "files": [
43
+ "index.ts",
44
+ "src",
45
+ "tasks"
46
+ ],
47
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
48
48
  "scripts": {
49
49
  "build": "projen build",
50
50
  "bump": "projen bump",
@@ -56,9 +56,7 @@
56
56
  "post-compile": "projen post-compile",
57
57
  "post-upgrade": "projen post-upgrade",
58
58
  "pre-compile": "projen pre-compile",
59
- "release": "projen release",
60
59
  "test": "projen test",
61
- "unbump": "projen unbump",
62
60
  "upgrade": "projen upgrade",
63
61
  "watch": "projen watch",
64
62
  "projen": "projen"
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Loading heavy generator tools out of the CONSUMING workspace.
3
+ *
4
+ * The generators (`openapi.ts`, `codegen.ts`) each drive a large toolchain that
5
+ * only matters when that generator actually has work to do, so none of them are
6
+ * imported at module scope and several are not engine dependencies at all. Node
7
+ * resolves a bare specifier from the engine's own location by walking up, and under
8
+ * pnpm that walk passes through `node_modules/.pnpm/node_modules` - the hidden
9
+ * directory holding every package installed ANYWHERE in the workspace. So a tool
10
+ * declared by some member package (`tsoa`, via the `server` tag) or by the root
11
+ * (`typescript`) resolves fine from here without the engine shipping its own copy.
12
+ *
13
+ * The failure mode is a consumer whose workspace never installed the tool, which
14
+ * surfaces as a bare MODULE_NOT_FOUND naming a package they never asked for. The
15
+ * helper below turns that into the install command instead.
16
+ */
17
+
18
+ /** How to obtain each lazily-loaded tool, for the error message. */
19
+ const INSTALL_HINTS: Record<string, string> = {
20
+ tsoa: "pnpm add tsoa",
21
+ typescript: "pnpm add -D typescript",
22
+ "ts-to-zod": "pnpm add -D ts-to-zod",
23
+ };
24
+
25
+ /**
26
+ * `require` a generator tool from the workspace, reporting a missing one as an
27
+ * actionable error rather than a raw MODULE_NOT_FOUND.
28
+ *
29
+ * @param require - a `createRequire(import.meta.url)` bound to the calling module,
30
+ * so the resolution walk starts at the engine and reaches the consumer's install.
31
+ * @param name - bare package specifier of the tool.
32
+ * @param reason - what the tool is needed for, named in the error.
33
+ */
34
+ export function lazyRequire<T>(require: NodeJS.Require, name: string, reason: string): T {
35
+ try {
36
+ return require(name) as T;
37
+ } catch (cause) {
38
+ if ((cause as NodeJS.ErrnoException)?.code !== "MODULE_NOT_FOUND") throw cause;
39
+ const hint = INSTALL_HINTS[name] ?? `pnpm add -D ${name}`;
40
+ throw new Error(
41
+ `${reason} needs the \`${name}\` package, which is not installed in this workspace. ` +
42
+ `Add it to the package that needs it (\`${hint}\`) and re-run.`,
43
+ { cause },
44
+ );
45
+ }
46
+ }
package/src/barrels.ts ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Barrel generator.
3
+ *
4
+ * For every package it writes a single `index.ts` **at the package root** (above
5
+ * `src/`) that namespace-re-exports every module under `src/`, subject to a few
6
+ * rules (see {@link isExcluded}):
7
+ * 1. a file/folder whose name starts with `_` is private and never barrelled;
8
+ * 2. test / `.d.ts` files are skipped;
9
+ * 3. a `src/**​/index.ts` is a hand-authored subpath entry, not a module;
10
+ * 4. only files that actually contain an `export` are re-exported.
11
+ *
12
+ * A hand-authored `exports.ts` sitting next to the generated `index.ts` (a Vite-style
13
+ * override) is spliced in last and wins: its exports are appended, and any generated
14
+ * `export * as <ns>` whose namespace it also declares is dropped so the custom one
15
+ * takes priority. This keeps the barrel auto-generated while letting you add or
16
+ * override individual exports.
17
+ *
18
+ * Each eligible module becomes `export * as <name> from "./src/x"` (camelCase
19
+ * namespace from its path segments; invalid identifiers suffixed with `Module`),
20
+ * sorted by module path.
21
+ *
22
+ * On top of the namespace lines, every export that is UNIQUE across the package
23
+ * (declared in exactly one module) is also HOISTED to the barrel's top level, so
24
+ * consumers can write `GenieMessage` or `DBXToolsNodeProject` instead of
25
+ * `genieModel.GenieMessage` / `project.DBXToolsNodeProject`. Types go out as
26
+ * `export type { ... }` (required under `isolatedModules`), values as
27
+ * `export { ... }`. The module namespaces stay either way, so a namespaced call
28
+ * site keeps working.
29
+ *
30
+ * Uniqueness is tallied over types and values TOGETHER: a name carried by two
31
+ * modules is ambiguous whichever kind it is, and hoisting one module's value
32
+ * beside another's same-named type would emit two conflicting re-exports. Such a
33
+ * name stays namespace-only. Names that collide with a generated namespace, or
34
+ * that a hand-authored `exports.ts` declares, are never hoisted (that file wins).
35
+ *
36
+ * The result gets a do-not-edit header + read-only bit (see `./generated`).
37
+ */
38
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { find } from "@dbx-tools/path";
41
+ import { string } from "@dbx-tools/shared-core";
42
+ import isIdentifier from "is-identifier";
43
+ import { header, makeReadonly, makeWritable, stampGenerated, type HeaderOpts } from "./generated";
44
+ import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports";
45
+ import { isModuleFile, toPosix, recordedPackages } from "./packages";
46
+
47
+ /**
48
+ * A `src`-relative posix path excluded from the root barrel:
49
+ * 1. any path segment starting with `_` (private module or folder);
50
+ * 2. a test / spec file;
51
+ * 3. a `.d.ts` declaration;
52
+ * 4. a `src/**​/index.ts` - a hand-authored subpath entry (e.g. `src/react/index.ts`
53
+ * behind a package's `./react` export), not a module to namespace into the barrel.
54
+ */
55
+ function isExcluded(relPath: string): boolean {
56
+ return (
57
+ /(^|\/)_/.test(relPath) ||
58
+ /\.(test|spec)\./.test(relPath) ||
59
+ /\.d\.ts$/.test(relPath) ||
60
+ /(^|\/)index\.ts$/.test(relPath)
61
+ );
62
+ }
63
+
64
+ /** Module file extension, for stripping to an extensionless module path. */
65
+ const MODULE_EXT_RE = /\.(tsx?|jsx?|mts|cts)$/;
66
+
67
+ /** True for a TypeScript source file (preferred over a compiled `.js` sibling). */
68
+ function isSourceExt(file: string): boolean {
69
+ return /\.(tsx?|mts|cts)$/.test(file);
70
+ }
71
+
72
+ /** Top-level statement types that make a file a re-exportable module. */
73
+ const EXPORT_STATEMENT_TYPES = new Set([
74
+ "ExportNamedDeclaration",
75
+ "ExportDefaultDeclaration",
76
+ "ExportAllDeclaration",
77
+ "TSExportAssignment",
78
+ ]);
79
+
80
+ /** True when the file has at least one top-level export statement. */
81
+ function hasExport(file: string): boolean {
82
+ return moduleStatements(file).some((stmt) => EXPORT_STATEMENT_TYPES.has(stmt.type));
83
+ }
84
+
85
+ /**
86
+ * The do-not-edit banner stamped on every generated barrel. Deliberately stable
87
+ * (no timestamp) so a barrel is a pure function of its exporting modules - which
88
+ * is what lets {@link generateForPackage} skip the rewrite when nothing changed.
89
+ */
90
+ const BARREL_HEADER: HeaderOpts = {
91
+ tool: "projen watch",
92
+ source: "the exporting modules in ./src",
93
+ };
94
+
95
+ /** `pnpm-workspace` -> `pnpmWorkspace`; nested paths join in camelCase. */
96
+ function kebabToCamel(segment: string): string {
97
+ return segment.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
98
+ }
99
+
100
+ /** Derive a valid namespace identifier from a relocated barrel module path. */
101
+ function modulePathToNamespace(modulePath: string): string {
102
+ const rel = modulePath.replace(/^\.\/src\//, "").replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
103
+ const segments = rel.split("/").map(kebabToCamel);
104
+ let name =
105
+ segments.length === 1
106
+ ? segments[0]!
107
+ : segments[0]! +
108
+ segments
109
+ .slice(1)
110
+ .map((s) => string.capitalize(s))
111
+ .join("");
112
+ if (!isIdentifier(name)) {
113
+ name = `${name}Module`;
114
+ }
115
+ return name;
116
+ }
117
+
118
+ /** A `./src/x` module path parsed out of a generated `export * as <ns>` line. */
119
+ function namespaceLines(content: string): { ns: string; modulePath: string }[] {
120
+ const out: { ns: string; modulePath: string }[] = [];
121
+ for (const line of content.split("\n")) {
122
+ const match = /^export \* as (\w+) from "(\.\/src\/.+)";\s*$/.exec(line);
123
+ if (match) out.push({ ns: match[1]!, modulePath: match[2]! });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Append hoisted top-level re-exports for every export that is UNIQUE across the
130
+ * package's modules - `export type { ... }` for types, `export { ... }` for
131
+ * values. A name declared by two or more modules is ambiguous and left
132
+ * namespace-only. `suppress` names (a hand-authored `exports.ts` surface) are
133
+ * never hoisted so that file stays authoritative.
134
+ */
135
+ function hoistUniqueExports(content: string, pkgDir: string, suppress: Set<string>): string {
136
+ const namespaces = namespaceLines(content);
137
+ if (namespaces.length === 0) return content;
138
+
139
+ // A hoisted name must never collide with a generated `export * as <ns>`
140
+ // namespace (e.g. a `mixin.ts` exporting a `mixin` value alongside the
141
+ // `export * as mixin` line), so treat every namespace id as suppressed too.
142
+ const blocked = new Set<string>(suppress);
143
+ for (const { ns } of namespaces) blocked.add(ns);
144
+
145
+ // Uniqueness is tallied over types AND values together - name -> { count,
146
+ // owning module } - so a name any two modules share stays namespace-only
147
+ // whichever kind each one is. `moduleExports` already dedupes within a module,
148
+ // so a count above one always means more than one module.
149
+ const seen = new Map<string, { count: number; modulePath: string }>();
150
+ const perModule = new Map<string, ModuleExport[]>();
151
+ for (const { modulePath } of namespaces) {
152
+ const abs = join(pkgDir, modulePath.replace(/^\.\//, ""));
153
+ const exports = moduleExports(withTsExt(abs));
154
+ perModule.set(modulePath, exports);
155
+ for (const { name } of exports) {
156
+ const prior = seen.get(name);
157
+ if (prior) prior.count += 1;
158
+ else seen.set(name, { count: 1, modulePath });
159
+ }
160
+ }
161
+
162
+ const lines: string[] = [];
163
+ for (const { modulePath } of namespaces) {
164
+ const types: string[] = [];
165
+ const values: string[] = [];
166
+ for (const { name, isType } of perModule.get(modulePath) ?? []) {
167
+ if (blocked.has(name)) continue;
168
+ const entry = seen.get(name);
169
+ // Unique across the package AND this is the module that owns it.
170
+ if (!entry || entry.count !== 1 || entry.modulePath !== modulePath) continue;
171
+ (isType ? types : values).push(name);
172
+ }
173
+ if (values.length) lines.push(`export { ${values.join(", ")} } from "${modulePath}";`);
174
+ if (types.length) lines.push(`export type { ${types.join(", ")} } from "${modulePath}";`);
175
+ }
176
+ if (lines.length === 0) return content;
177
+ return `${content.replace(/\n+$/, "")}\n${lines.join("\n")}\n`;
178
+ }
179
+
180
+ /** Resolve a barrel module path (`./src/x`, extensionless) to its on-disk `.ts(x)` file. */
181
+ function withTsExt(absNoExt: string): string {
182
+ for (const ext of [".ts", ".tsx", ".mts", ".cts"]) {
183
+ if (existsSync(absNoExt + ext)) return absNoExt + ext;
184
+ }
185
+ return `${absNoExt}.ts`;
186
+ }
187
+
188
+ /** Hand-authored override barrel: a sibling of the generated `index.ts`. */
189
+ const CUSTOM_EXPORTS_FILE = "exports.ts";
190
+
191
+ /**
192
+ * Best-effort set of the top-level export names a module declares - named
193
+ * declarations, `export { x }` specifiers, `export * as ns`, and default. A bare
194
+ * `export *` re-exports opaque names that can't be resolved statically, so a custom
195
+ * `exports.ts` should name what it means to override explicitly.
196
+ */
197
+ function customExportNames(file: string): Set<string> {
198
+ const names = new Set<string>();
199
+ const body = moduleStatements(file) as ReadonlyArray<Record<string, any>>;
200
+ const add = (node: Record<string, any> | undefined | null): void => {
201
+ if (node && typeof node.name === "string") names.add(node.name);
202
+ else if (node && typeof node.value === "string") names.add(node.value);
203
+ };
204
+ for (const stmt of body) {
205
+ if (stmt.type === "ExportDefaultDeclaration") {
206
+ names.add("default");
207
+ } else if (stmt.type === "ExportAllDeclaration") {
208
+ add(stmt.exported); // `export * as ns from ...`; a bare `export *` has none
209
+ } else if (stmt.type === "ExportNamedDeclaration") {
210
+ for (const spec of stmt.specifiers ?? []) add(spec.exported);
211
+ const decl = stmt.declaration;
212
+ if (decl?.id) add(decl.id);
213
+ for (const d of decl?.declarations ?? []) if (d.id?.type === "Identifier") add(d.id);
214
+ }
215
+ }
216
+ return names;
217
+ }
218
+
219
+ /**
220
+ * Splice a hand-authored `<pkg>/exports.ts` into the barrel. Any generated
221
+ * `export * as <ns>` whose namespace the custom file also declares is dropped (so the
222
+ * custom export wins - a plain `export *` cannot otherwise override an explicit
223
+ * `export * as`), then the whole module is re-exported last.
224
+ */
225
+ function mergeCustomExports(content: string, pkgDir: string): string {
226
+ if (!existsSync(join(pkgDir, CUSTOM_EXPORTS_FILE))) return content;
227
+ const overridden = customExportNames(join(pkgDir, CUSTOM_EXPORTS_FILE));
228
+ const kept = content.split("\n").filter((line) => {
229
+ const ns = /^export \* as (\w+) from /.exec(line)?.[1];
230
+ return !(ns && overridden.has(ns));
231
+ });
232
+ return `${kept.join("\n").replace(/\n+$/, "")}\nexport * from "./exports";\n`;
233
+ }
234
+
235
+ /**
236
+ * Rebuild one package's root barrel. Returns 1 only if the barrel's contents
237
+ * actually changed - a module was added, removed, renamed, or toggled its
238
+ * `export` - and 0 for a no-op. An edit *inside* an already-exported module (even
239
+ * adding a new named export) leaves the namespace `export * as … from "./src/x"`
240
+ * list identical, so it is a no-op.
241
+ */
242
+ function generateForPackage(pkgDir: string): number {
243
+ const srcDir = join(pkgDir, "src");
244
+ if (!existsSync(srcDir)) return 0;
245
+
246
+ const rootBarrel = join(pkgDir, "index.ts");
247
+ // Snapshot the current barrel so we can tell a real change (module added/removed/
248
+ // renamed) from an edit *inside* an already-exported module, which leaves the
249
+ // `export * as … from "./src/x"` list - and therefore this file - byte-for-byte
250
+ // identical.
251
+ const before = existsSync(rootBarrel) ? readFileSync(rootBarrel, "utf8") : undefined;
252
+
253
+ // The re-exportable module set under `src/`: every source file that actually
254
+ // exports something, minus the private / test / declaration files and any
255
+ // `src/**/index.ts` (those are hand-authored subpath entries behind a package's
256
+ // `./sub` export, not modules to namespace into the root barrel). `findFiles`
257
+ // yields posix paths relative to `srcDir`; `hasExport` parses each via
258
+ // `moduleStatements` and needs the absolute path.
259
+ const candidates = [...find.findFiles("**/*", { cwd: srcDir })]
260
+ .map(toPosix)
261
+ .filter(isModuleFile)
262
+ .filter((f) => !isExcluded(f))
263
+ .filter((f) => hasExport(join(srcDir, f)));
264
+
265
+ // Collapse each extensionless module path to one entry, preferring a TypeScript
266
+ // source over a sibling compiled artifact (`math.ts` wins over a committed
267
+ // `math.js`), so a module is barrelled exactly once. Then sort by module path.
268
+ const byModulePath = new Map<string, string>();
269
+ for (const f of candidates) {
270
+ const stem = f.replace(MODULE_EXT_RE, "");
271
+ const existing = byModulePath.get(stem);
272
+ if (!existing || (!isSourceExt(existing) && isSourceExt(f))) byModulePath.set(stem, f);
273
+ }
274
+ const modulePaths = [...byModulePath.keys()].sort((a, b) => a.localeCompare(b));
275
+
276
+ // No eligible modules -> no barrel: drop any stale root barrel and bail.
277
+ if (modulePaths.length === 0) {
278
+ if (existsSync(rootBarrel)) {
279
+ makeWritable(rootBarrel);
280
+ rmSync(rootBarrel, { force: true });
281
+ }
282
+ return 0;
283
+ }
284
+
285
+ // Unlock the read-only barrel so the rewrite below can replace it.
286
+ makeWritable(rootBarrel);
287
+
288
+ // `./src/<path-without-ext>` namespaced by its path segments (camelCase; invalid
289
+ // identifiers suffixed with `Module`).
290
+ let content = modulePaths
291
+ .map((stem) => {
292
+ const modulePath = `./src/${stem}`;
293
+ return `export * as ${modulePathToNamespace(modulePath)} from "${modulePath}";`;
294
+ })
295
+ .join("\n");
296
+ // Hoist package-unique named exports to the top level. Names a hand-authored
297
+ // `exports.ts` declares are suppressed so that file stays authoritative.
298
+ const customPath = join(pkgDir, CUSTOM_EXPORTS_FILE);
299
+ const suppress = existsSync(customPath) ? customExportNames(customPath) : new Set<string>();
300
+ content = hoistUniqueExports(content, pkgDir, suppress);
301
+ // A sibling `exports.ts` overrides/extends the generated barrel and wins on conflict.
302
+ content = mergeCustomExports(content, pkgDir);
303
+
304
+ // The barrel only *changes* when its set of exporting modules does. If the
305
+ // stamped result matches what's already on disk, restore the read-only bit we
306
+ // cleared above and report no change (0) - this keeps the watcher quiet on
307
+ // ordinary in-file edits (which leave the export * as … list identical).
308
+ content = `${content.replace(/\n+$/, "")}\n`;
309
+ const next = `${header(BARREL_HEADER)}\n${content}`;
310
+ if (before === next) {
311
+ makeReadonly(rootBarrel);
312
+ return 0;
313
+ }
314
+
315
+ writeFileSync(rootBarrel, content);
316
+ stampGenerated(rootBarrel, BARREL_HEADER);
317
+ return 1;
318
+ }
319
+
320
+ /**
321
+ * Rebuild barrels for the given package dirs (default: every package recorded in
322
+ * `pnpm-workspace.yaml` - the source of truth, read via `recordedPackages()`).
323
+ * Returns the number of barrels whose contents actually changed (an unchanged
324
+ * export surface is a no-op), so callers can stay quiet when nothing moved.
325
+ */
326
+ export function generateBarrels(opts: { dirs?: string[] } = {}): number {
327
+ const dirs = opts.dirs ?? recordedPackages().map((p) => p.dir);
328
+ let total = 0;
329
+ for (const dir of dirs) total += generateForPackage(dir);
330
+ return total;
331
+ }