@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/src/mixin.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Mixin factory for `constructs` {@link ConstructsMixin}.
3
+ *
4
+ * Apply with `construct.with(...)` across the subtree. Package-targeting mixins
5
+ * compose the `projectPredicate.hasIdentifierName` / `hasTag` / `hasPath`
6
+ * builders from `./project-predicate`; `project.applyToProjects` is the
7
+ * ergonomic front-end that assembles those and applies the mixin for you.
8
+ */
9
+ import type { Predicate } from "@dbx-tools/shared-core";
10
+ import type { IConstruct, IMixin as ConstructsMixin } from "constructs";
11
+
12
+ export type { ConstructsMixin };
13
+
14
+ /**
15
+ * Builds a {@link ConstructsMixin} from `supports` and `applyTo` callbacks.
16
+ *
17
+ * When `supports` is a type guard (`construct is U`) or a {@link Predicate},
18
+ * `applyTo` receives the narrowed `U`.
19
+ */
20
+ export function create<U extends IConstruct>(
21
+ supports: Predicate<IConstruct, U>,
22
+ applyTo: (construct: U) => void,
23
+ ): ConstructsMixin;
24
+
25
+ export function create<U extends IConstruct>(
26
+ supports: (construct: IConstruct) => construct is U,
27
+ applyTo: (construct: U) => void,
28
+ ): ConstructsMixin;
29
+
30
+ export function create(
31
+ supports: (construct: IConstruct) => boolean,
32
+ applyTo: (construct: IConstruct) => void,
33
+ ): ConstructsMixin;
34
+
35
+ export function create(
36
+ supports: (construct: IConstruct) => boolean,
37
+ applyTo: (construct: IConstruct) => void,
38
+ ): ConstructsMixin {
39
+ return {
40
+ supports,
41
+ applyTo: (construct: IConstruct): void => {
42
+ if (supports(construct)) {
43
+ (applyTo as (construct: IConstruct) => void)(construct);
44
+ }
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Static extraction of a module's own top-level named exports, via oxc-parser
3
+ * (a fast, TypeScript-aware parser). Used by the barrel generator to hoist
4
+ * names that are unique across a package to the top level of its barrel.
5
+ *
6
+ * Only a module's OWN declared names are returned - names it declares with
7
+ * `export const/function/class/enum/interface/type` or names it re-labels in a
8
+ * local `export { local as exported }`. Deliberately excluded:
9
+ *
10
+ * - `export default` (no stable importable name);
11
+ * - `export * from "..."` / `export * as ns from "..."` (opaque or already a
12
+ * namespace);
13
+ * - any `export { ... } from "..."` re-export with a `source` (the name is
14
+ * owned by another module, so hoisting it here would double-count).
15
+ *
16
+ * Each name carries whether it is TYPE-only (`interface` / `type` alias /
17
+ * `export type { ... }`), so the barrel can emit `export type { ... }` for it -
18
+ * required under `isolatedModules`, where re-exporting a type through a value
19
+ * `export { ... }` is a hard error (TS1205).
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { createRequire } from "node:module";
23
+ import type { parseSync as OxcParseSync } from "oxc-parser";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ /** oxc's `parseSync`, loaded lazily so importing this module stays cheap. */
28
+ let parseSyncFn: typeof OxcParseSync | undefined;
29
+ function parseSync(filename: string, source: string): ReturnType<typeof OxcParseSync> {
30
+ parseSyncFn ??= (require("oxc-parser") as typeof import("oxc-parser")).parseSync;
31
+ return parseSyncFn(filename, source);
32
+ }
33
+
34
+ /** A parsed top-level statement, narrowed to the discriminant every caller reads. */
35
+ export type ModuleStatement = { readonly type: string };
36
+
37
+ /**
38
+ * `file`'s top-level statements, or `[]` when it cannot be read or parsed. The
39
+ * single parse entry point for the whole engine: the barrel generator reads the
40
+ * same oxc AST to decide whether a file exports anything at all and to read a
41
+ * hand-authored `exports.ts`, so there is exactly one TypeScript parser here.
42
+ */
43
+ export function moduleStatements(file: string): readonly ModuleStatement[] {
44
+ let source: string;
45
+ try {
46
+ source = readFileSync(file, "utf8");
47
+ } catch {
48
+ return [];
49
+ }
50
+ try {
51
+ return parseSync(file, source).program.body;
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** One exported name plus whether it is type-only (needs `export type`). */
58
+ export interface ModuleExport {
59
+ readonly name: string;
60
+ readonly isType: boolean;
61
+ }
62
+
63
+ /** Declaration node types that are inherently type-only. */
64
+ const TYPE_DECLARATIONS = new Set(["TSInterfaceDeclaration", "TSTypeAliasDeclaration"]);
65
+
66
+ /**
67
+ * Parse `file` and return its own top-level named exports (see the module
68
+ * docstring for what's included). Returns `[]` on a read/parse error - a
69
+ * module the parser chokes on simply contributes no hoisted names.
70
+ */
71
+ export function moduleExports(file: string): ModuleExport[] {
72
+ const body = moduleStatements(file);
73
+
74
+ // Dedupe within the module: an overloaded `export function f(...)` declares
75
+ // `f` once per signature, but it's a single exported name. First occurrence
76
+ // wins (a value declaration and a same-named type would be unusual and are
77
+ // collapsed to whichever appears first).
78
+ const byName = new Map<string, ModuleExport>();
79
+ const push = (e: ModuleExport): void => {
80
+ if (!byName.has(e.name)) byName.set(e.name, e);
81
+ };
82
+ for (const stmt of body) {
83
+ if (stmt.type !== "ExportNamedDeclaration") continue;
84
+ // Narrow to the fields we read; oxc's union is wider than what we touch.
85
+ const node = stmt as {
86
+ exportKind?: "value" | "type";
87
+ source?: { value?: string } | null;
88
+ declaration?: {
89
+ type: string;
90
+ id?: { name?: string } | null;
91
+ declarations?: { id?: { type?: string; name?: string } | null }[];
92
+ } | null;
93
+ specifiers?: {
94
+ exported?: { name?: string; value?: string };
95
+ exportKind?: "value" | "type";
96
+ }[];
97
+ };
98
+ // `export { ... } from "..."` re-exports another module's names; skip.
99
+ if (node.source) continue;
100
+
101
+ const stmtIsType = node.exportKind === "type";
102
+ const decl = node.declaration;
103
+ if (decl) {
104
+ if (decl.id?.name) {
105
+ push({ name: decl.id.name, isType: stmtIsType || TYPE_DECLARATIONS.has(decl.type) });
106
+ }
107
+ for (const d of decl.declarations ?? []) {
108
+ if (d.id?.type === "Identifier" && d.id.name) {
109
+ push({ name: d.id.name, isType: stmtIsType });
110
+ }
111
+ }
112
+ }
113
+ for (const spec of node.specifiers ?? []) {
114
+ const name = spec.exported?.name ?? spec.exported?.value;
115
+ if (!name) continue;
116
+ push({ name, isType: stmtIsType || spec.exportKind === "type" });
117
+ }
118
+ }
119
+ return [...byName.values()];
120
+ }
package/src/openapi.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * OpenAPI generator (tsoa-based).
3
+ *
4
+ * Scans `server`/`node` packages for modules that **import tsoa**
5
+ * (`from 'tsoa'` / `from '@tsoa/runtime'`) and, for each package that has them,
6
+ * generates a read-only `<root>/openapi/<name>` package:
7
+ *
8
+ * - `openapi.json` - the OpenAPI 3 spec (tsoa `generateSpec`, from the types).
9
+ * - `src/schema.ts` - types generated from the spec (openapi-typescript).
10
+ * - `src/client.ts` - a typed `openapi-fetch` client, usable client-side.
11
+ *
12
+ * `generateSpec` then reads the actual controller decorators + TypeScript types from
13
+ * those files, so the API surface is annotated on the methods and nothing is
14
+ * hand-written twice. The generated client stack is openapi-typescript +
15
+ * openapi-fetch (openapi-ts.dev), the best-of-2026 choice since AppKit ships no
16
+ * OpenAPI client generator.
17
+ *
18
+ * `tsoa`, `typescript`, and `openapi-typescript` are loaded lazily (heavy, and only
19
+ * needed for `pnpm run openapi`), so importing this module stays cheap. `tsoa` and
20
+ * `typescript` are not engine dependencies at all - both are resolved out of the
21
+ * consuming workspace, which is where they already live.
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { createRequire } from "node:module";
25
+ import { join } from "node:path";
26
+ import type * as ts from "typescript";
27
+ import { find } from "@dbx-tools/path";
28
+ import { lazyRequire } from "./_lazy-require";
29
+ import { makeReadonly, makeWritable, stampGenerated } from "./generated";
30
+ import { log } from "@dbx-tools/shared-core";
31
+ import {
32
+ type RecordedPackage,
33
+ isModuleFile,
34
+ repoRoot,
35
+ toPosix,
36
+ recordedPackages,
37
+ } from "./packages";
38
+
39
+ const logger = log.logger("projen:openapi");
40
+
41
+ /** The tag (and folder) the generated openapi client packages are written under. */
42
+ const OPENAPI_TAG = "openapi";
43
+ /** Heuristic: a module file whose source imports tsoa's runtime package. */
44
+ const TSOA_IMPORT = /from\s+['"](?:tsoa|@tsoa\/runtime)['"]/;
45
+
46
+ const CLIENT_SRC = `import createClient, { type ClientOptions } from "openapi-fetch";
47
+ import type { paths } from "./schema";
48
+
49
+ /** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
50
+ export function createApiClient(options?: ClientOptions) {
51
+ return createClient<paths>(options);
52
+ }
53
+ `;
54
+
55
+ /** True if any module file in `<pkg>/src` matches {@link TSOA_IMPORT}. */
56
+ function hasTsoaControllers(pkg: Pick<RecordedPackage, "dir">): boolean {
57
+ const srcDir = join(pkg.dir, "src");
58
+ return [...find.findFiles("**/*", { cwd: srcDir })]
59
+ .filter(isModuleFile)
60
+ .some((f) => TSOA_IMPORT.test(readFileSync(join(srcDir, f), "utf8")));
61
+ }
62
+
63
+ /** `server`/`node` packages (never the generated `openapi` tag) with a tsoa import. */
64
+ function controllerPackages(): RecordedPackage[] {
65
+ return recordedPackages().filter(
66
+ (p) => (p.tags.includes("server") || p.tags.includes("node")) && hasTsoaControllers(p),
67
+ );
68
+ }
69
+
70
+ /** True if the changed path is a source file that matches {@link TSOA_IMPORT}. */
71
+ export function isTsoaController(path: string): boolean {
72
+ const posix = toPosix(path);
73
+ return (
74
+ !posix.includes(`/${OPENAPI_TAG}/`) &&
75
+ isModuleFile(path) &&
76
+ existsSync(path) &&
77
+ TSOA_IMPORT.test(readFileSync(path, "utf8"))
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Regenerate the `openapi` packages from every server/node package with a tsoa
83
+ * import. Returns the package dirs it wrote so the caller can rebuild their barrels.
84
+ * A separate projen synth is still needed before new openapi folders become workspace
85
+ * members in `pnpm-workspace.yaml`.
86
+ */
87
+ export async function generateOpenapi(): Promise<string[]> {
88
+ const pkgs = controllerPackages();
89
+ if (pkgs.length === 0) {
90
+ logger.info("no tsoa controllers found in any server/node package");
91
+ return [];
92
+ }
93
+
94
+ // Lazy, resilient loads: tsoa + typescript are CJS (require), openapi-typescript
95
+ // is ESM (dynamic import).
96
+ const require = createRequire(import.meta.url);
97
+ const { generateSpec } = lazyRequire<typeof import("tsoa")>(require, "tsoa", "openapi generation");
98
+ const tsRuntime = lazyRequire<typeof ts>(require, "typescript", "openapi generation");
99
+ const { default: openapiTS, astToString } = await import("openapi-typescript");
100
+
101
+ // Read tsoa's controllers with decorator support; skipLibCheck keeps third-party
102
+ // `.d.ts` out of the spec-generation compile.
103
+ const compilerOptions: ts.CompilerOptions = {
104
+ experimentalDecorators: true,
105
+ target: tsRuntime.ScriptTarget.ES2022,
106
+ module: tsRuntime.ModuleKind.ESNext,
107
+ moduleResolution: tsRuntime.ModuleResolutionKind.Bundler,
108
+ esModuleInterop: true,
109
+ skipLibCheck: true,
110
+ };
111
+
112
+ const written: string[] = [];
113
+ for (const p of pkgs) {
114
+ // The generated package's folder is the source's leaf folder name (`api`), not
115
+ // its npm name - `p.name` is now the (possibly-overridden) manifest name.
116
+ const leaf = p.relPath.split("/").pop() ?? p.relPath;
117
+ const outDir = join(repoRoot, p.root, OPENAPI_TAG, leaf);
118
+ const srcDir = join(outDir, "src");
119
+ mkdirSync(srcDir, { recursive: true });
120
+
121
+ // 1) tsoa writes <outDir>/openapi.json from the controllers' decorators + types.
122
+ const specPath = join(outDir, "openapi.json");
123
+ makeWritable(specPath);
124
+ await generateSpec(
125
+ {
126
+ entryFile: "",
127
+ noImplicitAdditionalProperties: "throw-on-extras",
128
+ controllerPathGlobs: [join(p.dir, "src/**/*.ts")],
129
+ outputDirectory: outDir,
130
+ specFileBaseName: "openapi",
131
+ specVersion: 3,
132
+ name: `${p.relPath} API`,
133
+ version: "0.0.0",
134
+ },
135
+ compilerOptions,
136
+ );
137
+ makeReadonly(specPath);
138
+
139
+ // 2) src/schema.ts: types generated from the spec (openapi-typescript).
140
+ const spec = JSON.parse(readFileSync(specPath, "utf8"));
141
+ const schemaPath = join(srcDir, "schema.ts");
142
+ makeWritable(schemaPath);
143
+ writeFileSync(schemaPath, astToString(await openapiTS(spec)));
144
+ stampGenerated(schemaPath, {
145
+ tool: "projen openapi (tsoa + openapi-typescript)",
146
+ source: `the tsoa controllers in ${p.relPath}`,
147
+ });
148
+
149
+ // 3) src/client.ts: a typed openapi-fetch client over those types.
150
+ const clientPath = join(srcDir, "client.ts");
151
+ makeWritable(clientPath);
152
+ writeFileSync(clientPath, CLIENT_SRC);
153
+ stampGenerated(clientPath, {
154
+ tool: "projen openapi (openapi-fetch)",
155
+ source: "./schema",
156
+ });
157
+
158
+ written.push(outDir);
159
+ logger.success(`openapi/${leaf} (from ${p.relPath})`);
160
+ }
161
+ return written;
162
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Package discovery + shared filesystem helpers.
3
+ *
4
+ * Terminology (Bit-style): a **tag** names a target environment
5
+ * (React/Vite, Node, agnostic, ...); a workspace **package** is a folder with a
6
+ * `src/` holding at least one module file (`.ts`/`.tsx`/`.js`/`.jsx`). "Scope" is
7
+ * reserved for the npm `@scope/` in package identifiers (e.g. `@dbx-tools/ui-app`).
8
+ *
9
+ * A package is discovered by scanning the {@link packageRoots} (default
10
+ * `["packages"]`). Its path *relative to the root* drives everything: the path
11
+ * segments join with `-` cumulatively into {@link DiscoveredPackage.tagCandidates}
12
+ * (e.g. `shared/path/coolDude/another` -> `[shared, shared-path, shared-path-cool-dude]`:
13
+ * each ancestor folder under the root, kebab-cased, excluding the leaf package
14
+ * folder), and those candidates are matched against `packageTagPaths` to decide which tag(s)
15
+ * apply. The match may yield NO tags - that is fine (the package still gets the
16
+ * agnostic default).
17
+ *
18
+ * Two discovery entry points. {@link scanPackages} walks the filesystem under the
19
+ * roots (synth time): it returns each package's path plus the tags implied by its
20
+ * path relative to the root, reading NO manifest. {@link recordedPackages} reads
21
+ * the recorded members from `pnpm-workspace.yaml` - the SOURCE OF TRUTH - and
22
+ * augments each with the `name` and `tags` read back from its own `package.json`
23
+ * (post-synth: barrels, watch, openapi), which is authoritative and so reflects any
24
+ * synth-time name override or resolved tag set.
25
+ */
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { extname, relative, resolve, sep } from "node:path";
28
+ import { project as coreProject } from "@dbx-tools/core";
29
+ import { json, object, string } from "@dbx-tools/shared-core";
30
+ import { find } from "@dbx-tools/path";
31
+ import { parse } from "yaml";
32
+
33
+ /**
34
+ * The repo root: the nearest package/projenrc root, falling back to the current
35
+ * working directory. Detection (npm prefix, git top-level, root markers) lives
36
+ * in `@dbx-tools/core`'s {@link coreProject.root}.
37
+ */
38
+ export const repoRoot = coreProject.root() ?? process.cwd();
39
+
40
+ /**
41
+ * Default package roots. Each is scanned for packages; override via the
42
+ * `packageRoots` option on a DBXTools project.
43
+ */
44
+ export const DEFAULT_PACKAGE_ROOTS = ["packages"] as const;
45
+
46
+ /**
47
+ * A project name: the root `package.json` name, else the git remote's repo
48
+ * name, else the root folder name. Delegates to `@dbx-tools/core`'s
49
+ * {@link coreProject.name}, which is also what a consuming repo's own tooling
50
+ * sees, so the engine and its host agree on the name.
51
+ */
52
+ export function projectName(): string {
53
+ return coreProject.name(repoRoot);
54
+ }
55
+
56
+ const MODULE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
57
+
58
+ /** Glob for module files under any `src/`, built from {@link MODULE_EXTS} exts. */
59
+ const SRC_MODULE_GLOB = `**/src/**/*.{${[...MODULE_EXTS].map((e) => e.slice(1)).join(",")}}`;
60
+
61
+ export function toPosix(p: string): string {
62
+ return p.split(sep).join("/");
63
+ }
64
+
65
+ /**
66
+ * Cumulative nesting tags from a package's path segments relative to its discovery
67
+ * root. Each segment is kebab-cased with {@link string.toSlug} (`coolDude` ->
68
+ * `cool-dude`). The leaf folder (the package name) is excluded when there are two
69
+ * or more segments; a lone segment tags itself.
70
+ */
71
+ function nestingTagsFromSegments(segments: readonly string[]): string[] {
72
+ if (segments.length === 0) return [];
73
+ const prefix = segments.length === 1 ? segments : segments.slice(0, -1);
74
+ const out: string[] = [];
75
+ let acc = "";
76
+ for (const segment of prefix) {
77
+ const token = string.toSlug(segment);
78
+ if (!token) continue;
79
+ acc = acc ? `${acc}-${token}` : token;
80
+ out.push(acc);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Matches a barrel `index.<ext>` (as a basename or a posix path tail). */
86
+ const BARREL_RE = /(^|\/)index\.(ts|tsx|js|jsx|mjs|cjs)$/;
87
+
88
+ /** Basenames this toolchain generates (projen manifests/tsconfigs + vite config). */
89
+ const GENERATED_BASENAMES = new Set([
90
+ "package.json",
91
+ "tsconfig.json",
92
+ "tsconfig.dev.json",
93
+ "vite.config.ts",
94
+ ]);
95
+
96
+ /**
97
+ * True if the file matches the watcher's generated-file heuristic: projen manifest
98
+ * basenames, package-root barrels (`index.ts`), vite config, or declaration files.
99
+ * Other read-only toolchain output (e.g. openapi artifacts) is not covered here.
100
+ */
101
+ export function isGeneratedFile(file: string): boolean {
102
+ const base = file.split(sep).pop() ?? "";
103
+ return GENERATED_BASENAMES.has(base) || BARREL_RE.test(base) || base.endsWith(".d.ts");
104
+ }
105
+
106
+ /** A re-exportable source module: ts/tsx/js/jsx/mjs/cjs, not a barrel/test/decl. */
107
+ export function isModuleFile(file: string): boolean {
108
+ if (file.endsWith(".d.ts")) return false;
109
+ if (!MODULE_EXTS.has(extname(file))) return false;
110
+ // Accept both OS-native paths and posix (glob) inputs.
111
+ const base = toPosix(file).split("/").pop()!;
112
+ if (BARREL_RE.test(base)) return false;
113
+ if (/\.(test|spec)\./.test(base)) return false;
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * One discovered package: a `src`-bearing folder somewhere under a
119
+ * package root, identified by that root plus the segments of its path
120
+ * *relative to the root*. For `packages/ui/app` the root is `packages` and the
121
+ * segments are `["ui", "app"]`.
122
+ *
123
+ * The relative segments drive everything downstream: the npm name
124
+ * (`@<scope>/<segments joined by -`), the `memberPath`/`dir`, and the
125
+ * {@link tagCandidates} used to resolve which tag(s) apply.
126
+ */
127
+ export class DiscoveredPackage {
128
+ constructor(
129
+ /** Absolute repo root. */
130
+ readonly projectRoot: string,
131
+ /** Repo-relative package root, e.g. `packages`. */
132
+ readonly root: string,
133
+ /** Path segments relative to `root`, e.g. `["ui", "app"]`. */
134
+ readonly relSegments: readonly string[],
135
+ ) {}
136
+
137
+ /** Posix path relative to the root, e.g. `ui/app`. */
138
+ get relPath(): string {
139
+ return this.relSegments.join("/");
140
+ }
141
+
142
+ /** Repo-relative posix member path: `packages/ui/app` (pnpm member + `outdir`). */
143
+ get memberPath(): string {
144
+ return [this.root, ...this.relSegments].join("/");
145
+ }
146
+
147
+ /** Absolute package directory. */
148
+ get dir(): string {
149
+ return resolve(this.projectRoot, this.root, ...this.relSegments);
150
+ }
151
+
152
+ /** The package folder name (last segment), e.g. `app`. */
153
+ get name(): string {
154
+ return this.relSegments[this.relSegments.length - 1] ?? this.root;
155
+ }
156
+
157
+ /**
158
+ * Tag candidates from nesting under the discovery root: cumulative kebab-case join
159
+ * of every ancestor folder, excluding the leaf package folder when depth >= 2
160
+ * (`shared/path/coolDude/another` -> `[shared, shared-path, shared-path-cool-dude]`).
161
+ * Matched against `packageTagPaths` to resolve applied mixin tags.
162
+ */
163
+ get tagCandidates(): string[] {
164
+ return nestingTagsFromSegments(this.relSegments);
165
+ }
166
+ }
167
+
168
+ /** Read the raw workspace member globs from `pnpm-workspace.yaml` (source of truth). */
169
+ function readRecordedMembers(projectRoot: string = repoRoot): string[] {
170
+ const file = resolve(projectRoot, "pnpm-workspace.yaml");
171
+ if (!existsSync(file)) return [];
172
+ const doc = parse(readFileSync(file, "utf8")) as {
173
+ packages?: string[];
174
+ } | null;
175
+ return doc?.packages ?? [];
176
+ }
177
+
178
+ /** A member path `<root>/<...rel>` (>= 2 segments) as a {@link DiscoveredPackage}. */
179
+ function packageOfMember(projectRoot: string, member: string): DiscoveredPackage | undefined {
180
+ const segs = toPosix(member).split("/").filter(Boolean);
181
+ if (segs.length < 2) return undefined;
182
+ return new DiscoveredPackage(projectRoot, segs[0]!, segs.slice(1));
183
+ }
184
+
185
+ /**
186
+ * Package dirs under `rootAbs`, found with a single `find.findFiles` scan from
187
+ * `@dbx-tools/path` for module files beneath any `src/`. A package is the
188
+ * folder that OWNS the `src/` - the segments before the FIRST `src/` - so a package's
189
+ * own subfolders never become nested packages (outermost wins). Barrels/tests/decls
190
+ * don't count (see {@link isModuleFile}), so a `src/` holding only an `index.ts`
191
+ * barrel is not a package. Depth is unbounded: `<root>/a/b/c/src` is discovered as
192
+ * `a/b/c`.
193
+ */
194
+ function collectPackageDirs(rootAbs: string): string[] {
195
+ const owners = new Set<string>();
196
+ for (const file of find.findFiles(SRC_MODULE_GLOB, { cwd: rootAbs })) {
197
+ if (!isModuleFile(file)) continue;
198
+ const segs = toPosix(file).split("/");
199
+ const srcIdx = segs.indexOf("src");
200
+ if (srcIdx > 0) owners.add(segs.slice(0, srcIdx).join("/"));
201
+ }
202
+ const rels = [...owners];
203
+ // Outermost wins: drop any owner nested under another discovered owner.
204
+ return rels
205
+ .filter((d) => !rels.some((o) => o !== d && d.startsWith(`${o}/`)))
206
+ .map((rel) => resolve(rootAbs, rel));
207
+ }
208
+
209
+ /**
210
+ * Scan the filesystem for packages under `roots` (synth time): every `src`-bearing
211
+ * folder, at any depth, is one. Returns each as a {@link DiscoveredPackage} - its
212
+ * path plus the tags implied by its path relative to the root
213
+ * ({@link DiscoveredPackage.tagCandidates}); no `package.json` is read. Used by
214
+ * the root project's scan at synth, and by the watcher to compare disk against the
215
+ * recorded set. Sorted by member path.
216
+ */
217
+ export function scanPackages(
218
+ projectRoot: string = repoRoot,
219
+ roots: readonly string[] = DEFAULT_PACKAGE_ROOTS,
220
+ ): DiscoveredPackage[] {
221
+ const out: DiscoveredPackage[] = [];
222
+ for (const root of roots) {
223
+ const rootAbs = resolve(projectRoot, root);
224
+ for (const pkgDir of collectPackageDirs(rootAbs)) {
225
+ const rel = toPosix(relative(rootAbs, pkgDir)).split("/").filter(Boolean);
226
+ out.push(new DiscoveredPackage(projectRoot, root, rel));
227
+ }
228
+ }
229
+ return out.sort((a, b) => a.memberPath.localeCompare(b.memberPath));
230
+ }
231
+
232
+ /**
233
+ * A recorded package: its path, plus the `name` and `tags` read back from
234
+ * its own `package.json` (both written at synth, and possibly overridden by a consumer
235
+ * `packageMixin` - e.g. a name override). `name`/`tags` fall back to the folder
236
+ * name / path candidates when the manifest is missing or carries none (a package
237
+ * added but not yet synthesized).
238
+ */
239
+ export interface RecordedPackage {
240
+ /** Repo-relative posix member path, e.g. `packages/ui/app`. */
241
+ readonly path: string;
242
+ /** Repo-relative package root, e.g. `packages`. */
243
+ readonly root: string;
244
+ /** Posix path relative to the root, e.g. `ui/app`. */
245
+ readonly relPath: string;
246
+ /** Absolute package directory. */
247
+ readonly dir: string;
248
+ /** Resolved tags from `package.json` `dbxToolsConfig.tags`, else the path candidates. */
249
+ readonly tags: string[];
250
+ }
251
+
252
+ /**
253
+ * `<dir>/package.json` parsed as a record, or `undefined` when the file is
254
+ * absent or malformed. The single manifest reader for the engine - every
255
+ * caller that pokes at a `package.json` field goes through this so a missing or
256
+ * half-written manifest degrades the same way everywhere.
257
+ */
258
+ export function readPackageManifest(dir: string): Record<string, unknown> | undefined {
259
+ const path = resolve(dir, "package.json");
260
+ if (!existsSync(path)) return undefined;
261
+ try {
262
+ return json.parseRecord(readFileSync(path, "utf8"));
263
+ } catch {
264
+ return undefined; // unreadable (permissions, race with a concurrent write)
265
+ }
266
+ }
267
+
268
+ /** A package's `dbxToolsConfig` object, or `undefined` when absent. */
269
+ function readDbxToolsConfig(dir: string): Record<string, unknown> | undefined {
270
+ const config = readPackageManifest(dir)?.dbxToolsConfig;
271
+ return object.isRecord(config) ? config : undefined;
272
+ }
273
+
274
+ /** Read `<dir>/package.json`'s `dbxToolsConfig.tags` (`undefined` if absent). */
275
+ function readManifestTags(dir: string): string[] | undefined {
276
+ const tags = readDbxToolsConfig(dir)?.tags;
277
+ return Array.isArray(tags) ? (tags as string[]) : undefined;
278
+ }
279
+
280
+ /**
281
+ * Extra repo-root paths that trigger a full re-synth during `sync --watch`, read from
282
+ * the root `package.json` `dbxToolsConfig.syncResynthPaths` (set via the
283
+ * {@link DBXToolsProjectOptions.syncResynthPaths} option at synth).
284
+ */
285
+ export function syncResynthPaths(projectRoot: string = repoRoot): string[] {
286
+ const paths = readDbxToolsConfig(projectRoot)?.syncResynthPaths;
287
+ return Array.isArray(paths) ? string.parseList(paths.map((p) => String(p))) : [];
288
+ }
289
+
290
+ /**
291
+ * The recorded workspace members from `pnpm-workspace.yaml` (the source of truth),
292
+ * each augmented with the `tags` read back from its `package.json`. This is what
293
+ * every post-synth command (barrels, watch, openapi) uses: the manifest is
294
+ * authoritative, so it reflects the resolved tag set.
295
+ * Sorted by path.
296
+ */
297
+ export function recordedPackages(projectRoot: string = repoRoot): RecordedPackage[] {
298
+ const out: RecordedPackage[] = [];
299
+ for (const member of readRecordedMembers(projectRoot)) {
300
+ const pkg = packageOfMember(projectRoot, member);
301
+ if (!pkg) continue;
302
+ out.push({
303
+ path: pkg.memberPath,
304
+ root: pkg.root,
305
+ relPath: pkg.relPath,
306
+ dir: pkg.dir,
307
+ tags: readManifestTags(pkg.dir) ?? pkg.tagCandidates,
308
+ });
309
+ }
310
+ return out.sort((a, b) => a.path.localeCompare(b.path));
311
+ }
312
+
313
+ /**
314
+ * The roots to scan for a live filesystem check: the distinct first segment of
315
+ * every recorded member, unioned with the defaults. Lets a command compare disk
316
+ * against the recorded truth without knowing the `packageRoots` the last
317
+ * synth was configured with.
318
+ */
319
+ export function recordedRoots(projectRoot: string = repoRoot): string[] {
320
+ const roots = new Set<string>(DEFAULT_PACKAGE_ROOTS);
321
+ for (const member of readRecordedMembers(projectRoot)) {
322
+ const pkg = packageOfMember(projectRoot, member);
323
+ if (pkg) roots.add(pkg.root);
324
+ }
325
+ return [...roots];
326
+ }