@howells/boundaries 0.1.0

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 ADDED
@@ -0,0 +1,79 @@
1
+ # @howells/boundaries
2
+
3
+ Opinionated package-boundary conventions for Turborepo workspaces.
4
+
5
+ The executable is `boundaries`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add -D @howells/boundaries
11
+ ```
12
+
13
+ ## Use
14
+
15
+ Initialize boundary config:
16
+
17
+ ```sh
18
+ pnpm exec boundaries init
19
+ ```
20
+
21
+ This adds:
22
+
23
+ - root `turbo.json` boundary rules
24
+ - package-level `turbo.json` tags
25
+ - a root `package.json` script: `"boundaries": "boundaries check"`
26
+
27
+ Run checks:
28
+
29
+ ```sh
30
+ pnpm boundaries
31
+ ```
32
+
33
+ or:
34
+
35
+ ```sh
36
+ pnpm exec boundaries check
37
+ ```
38
+
39
+ Explain a relationship:
40
+
41
+ ```sh
42
+ pnpm exec boundaries explain apps/web packages/ui
43
+ pnpm exec boundaries explain apps/web apps/admin
44
+ ```
45
+
46
+ ## Default Policy
47
+
48
+ The default tags are:
49
+
50
+ ```text
51
+ type:app
52
+ type:package
53
+ type:tooling
54
+ scope:<workspace-name>
55
+ visibility:public
56
+ visibility:internal
57
+ ```
58
+
59
+ The default rules are:
60
+
61
+ ```text
62
+ type:app cannot depend on type:app
63
+ type:package cannot depend on type:app
64
+ type:tooling cannot depend on type:app
65
+ ```
66
+
67
+ This blocks app-to-app imports and keeps shared packages from reaching into deployable apps.
68
+
69
+ `visibility:*` tags are generated as metadata, but no default rule is attached to them yet. Public packages often depend on private dev tooling packages, so runtime visibility policy needs a more precise model.
70
+
71
+ ## Backend
72
+
73
+ `boundaries check` validates the generated convention layer, then delegates to:
74
+
75
+ ```sh
76
+ turbo boundaries
77
+ ```
78
+
79
+ Use this in Turborepo repos that already have `turbo` installed.
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@howells/boundaries",
3
+ "version": "0.1.0",
4
+ "description": "Opinionated Turborepo package boundary conventions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "boundaries": "./src/cli.js"
8
+ },
9
+ "license": "MIT",
10
+ "files": [
11
+ "README.md",
12
+ "src",
13
+ "skills"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ }
21
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: howells-boundaries
3
+ description: Use when adding, checking, explaining, or repairing package-level architecture boundaries in Turborepo JavaScript/TypeScript monorepos with the `boundaries` CLI from `@howells/boundaries`.
4
+ ---
5
+
6
+ # Howells Boundaries
7
+
8
+ Use `@howells/boundaries` for package-level architecture enforcement in Turborepo workspaces. The executable is `boundaries`.
9
+
10
+ ## Workflow
11
+
12
+ 1. Confirm the repo is a Turborepo workspace by checking for `turbo.json` and workspace packages in `package.json`, `pnpm-workspace.yaml`, or equivalent package-manager config.
13
+ 2. Install or use `@howells/boundaries` from the repo’s chosen package manager.
14
+ 3. Run `boundaries init` to add root Turbo boundary rules and per-package `turbo.json` tags.
15
+ 4. Review generated tags before accepting them. Fix incorrect tags instead of weakening policy.
16
+ 5. Run `boundaries check`.
17
+ 6. If a violation appears, prefer fixing the import or dependency declaration. Use exceptions only when they are narrow, temporary, and documented.
18
+
19
+ ## Default Model
20
+
21
+ Use these package tags unless the repo already has a clearer convention:
22
+
23
+ ```text
24
+ type:app
25
+ type:package
26
+ type:tooling
27
+ scope:<name>
28
+ platform:browser
29
+ platform:node
30
+ visibility:public
31
+ visibility:internal
32
+ ```
33
+
34
+ Default rules:
35
+
36
+ ```text
37
+ type:app cannot depend on type:app
38
+ type:package cannot depend on type:app
39
+ type:tooling cannot depend on type:app
40
+ ```
41
+
42
+ This blocks app-to-app imports and keeps shared packages from reaching into deployable apps. Treat `visibility:*` as metadata until the checker can distinguish runtime dependencies from dev-only tooling dependencies.
43
+
44
+ ## Good Fixes
45
+
46
+ - Move shared code from an app into a package, then import the package.
47
+ - Add a missing internal package dependency to the importing package’s `package.json`.
48
+ - Use the package public entrypoint instead of importing files across package directories.
49
+ - Split package tags by purpose when a package has unclear ownership.
50
+
51
+ ## Avoid
52
+
53
+ - Do not use ESLint as the primary enforcement mechanism.
54
+ - Do not add broad allowlists to make a check pass.
55
+ - Do not move task logic into root `package.json`; keep Turbo package tasks in packages and root scripts as delegators.
56
+ - Do not invent package-internal layer rules for this tool. Keep v1 package-level; use another tool later for module-level boundaries if needed.
57
+
58
+ ## Fallbacks
59
+
60
+ Use `turbo boundaries` as the backend when available. Consider `dependency-cruiser` or `rev-dep` only when the user asks for graph analysis that Turbo boundaries cannot answer.
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "Howells Boundaries"
3
+ short_description: "Turborepo package boundary enforcement"
4
+ default_prompt: "Use $howells-boundaries to add and check package-level boundaries in this Turborepo workspace."
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
package/src/check.js ADDED
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { discoverWorkspaces } from "./init.js";
6
+
7
+ export async function checkRepository({
8
+ root = process.cwd(),
9
+ runTurbo = true,
10
+ stdout = process.stdout,
11
+ stderr = process.stderr,
12
+ } = {}) {
13
+ const rootPackageJson = await readJson(join(root, "package.json"));
14
+ const rootTurboJson = await readJson(join(root, "turbo.json"), {});
15
+ const workspaces = await discoverWorkspaces(root, rootPackageJson);
16
+ const errors = await validateBoundarySetup(root, rootTurboJson, workspaces);
17
+
18
+ if (errors.length > 0) {
19
+ for (const error of errors) {
20
+ stderr.write(`boundaries: ${error}\n`);
21
+ }
22
+ return { ok: false, exitCode: 1, errors };
23
+ }
24
+
25
+ if (!runTurbo) {
26
+ stdout.write(`Boundary configuration is valid for ${workspaces.length} workspace${workspaces.length === 1 ? "" : "s"}.\n`);
27
+ return { ok: true, exitCode: 0, errors: [] };
28
+ }
29
+
30
+ const turboExitCode = await runTurboBoundaries({ root });
31
+ return {
32
+ ok: turboExitCode === 0,
33
+ exitCode: turboExitCode,
34
+ errors: [],
35
+ };
36
+ }
37
+
38
+ async function validateBoundarySetup(root, rootTurboJson, workspaces) {
39
+ const errors = [];
40
+
41
+ if (!rootTurboJson.boundaries?.tags) {
42
+ errors.push("root turbo.json is missing boundaries.tags; run `boundaries init`.");
43
+ }
44
+
45
+ for (const workspace of workspaces) {
46
+ const packageTurboJson = await readJson(join(root, workspace.path, "turbo.json"), {});
47
+ if (!Array.isArray(packageTurboJson.tags) || packageTurboJson.tags.length === 0) {
48
+ errors.push(`${workspace.path}/turbo.json is missing package boundary tags.`);
49
+ }
50
+ }
51
+
52
+ return errors;
53
+ }
54
+
55
+ function runTurboBoundaries({ root }) {
56
+ return new Promise((resolve) => {
57
+ const child = spawn("turbo", ["boundaries"], {
58
+ cwd: root,
59
+ shell: process.platform === "win32",
60
+ stdio: "inherit",
61
+ });
62
+
63
+ child.on("error", (error) => {
64
+ process.stderr.write(
65
+ `boundaries: could not run \`turbo boundaries\` (${error.message}). Install turbo or run \`boundaries check --no-turbo\`.\n`,
66
+ );
67
+ resolve(1);
68
+ });
69
+
70
+ child.on("close", (code) => {
71
+ resolve(code ?? 1);
72
+ });
73
+ });
74
+ }
75
+
76
+ async function readJson(filePath, fallback = undefined) {
77
+ try {
78
+ return JSON.parse(await readFile(filePath, "utf8"));
79
+ } catch (error) {
80
+ if (error.code === "ENOENT" && fallback !== undefined) {
81
+ return fallback;
82
+ }
83
+ throw error;
84
+ }
85
+ }
package/src/cli.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { checkRepository } from "./check.js";
6
+ import { evaluateDependency } from "./core.js";
7
+ import { discoverWorkspaces, initRepository } from "./init.js";
8
+
9
+ const HELP = `Usage: boundaries <command>
10
+
11
+ Commands:
12
+ init Add Howells boundary conventions to a Turborepo workspace
13
+ check [--no-turbo] Validate boundary config and run turbo boundaries
14
+ explain <from> <to> Explain whether one workspace may depend on another
15
+ help Show this help
16
+ `;
17
+
18
+ async function main(argv) {
19
+ const [command, ...args] = argv;
20
+
21
+ if (!command || command === "help" || command === "--help" || command === "-h") {
22
+ process.stdout.write(HELP);
23
+ return 0;
24
+ }
25
+
26
+ if (command === "init") {
27
+ const result = await initRepository();
28
+ process.stdout.write(
29
+ `Initialized boundaries for ${result.workspaces.length} workspace${result.workspaces.length === 1 ? "" : "s"}.\n`,
30
+ );
31
+ return 0;
32
+ }
33
+
34
+ if (command === "check") {
35
+ const result = await checkRepository({
36
+ runTurbo: !args.includes("--no-turbo"),
37
+ });
38
+ return result.exitCode;
39
+ }
40
+
41
+ if (command === "explain") {
42
+ return explain(args);
43
+ }
44
+
45
+ process.stderr.write(`Unknown command: ${command}\n\n${HELP}`);
46
+ return 1;
47
+ }
48
+
49
+ async function explain(args) {
50
+ const [fromSelector, toSelector] = args;
51
+ if (!fromSelector || !toSelector) {
52
+ process.stderr.write("Usage: boundaries explain <from> <to>\n");
53
+ return 1;
54
+ }
55
+
56
+ const root = process.cwd();
57
+ const rootPackageJson = await readJson(join(root, "package.json"));
58
+ const rootTurboJson = await readJson(join(root, "turbo.json"));
59
+ const workspaces = await discoverWorkspaces(root, rootPackageJson);
60
+ const from = findWorkspace(workspaces, fromSelector);
61
+ const to = findWorkspace(workspaces, toSelector);
62
+
63
+ if (!from || !to) {
64
+ process.stderr.write("Could not find both workspaces. Use a package name or workspace path.\n");
65
+ return 1;
66
+ }
67
+
68
+ const decision = evaluateDependency({
69
+ rootConfig: rootTurboJson,
70
+ fromName: from.name,
71
+ fromTags: await readTags(root, from),
72
+ toName: to.name,
73
+ toTags: await readTags(root, to),
74
+ });
75
+
76
+ process.stdout.write(
77
+ `${from.name ?? from.path} -> ${to.name ?? to.path}: ${decision.allowed ? "allowed" : "blocked"}\n${decision.reason}\n`,
78
+ );
79
+
80
+ return decision.allowed ? 0 : 1;
81
+ }
82
+
83
+ function findWorkspace(workspaces, selector) {
84
+ return workspaces.find((workspace) => {
85
+ return workspace.name === selector || workspace.path === selector || workspace.path.endsWith(`/${selector}`);
86
+ });
87
+ }
88
+
89
+ async function readTags(root, workspace) {
90
+ const turboJson = await readJson(join(root, workspace.path, "turbo.json"), {});
91
+ return turboJson.tags ?? [];
92
+ }
93
+
94
+ async function readJson(filePath, fallback = undefined) {
95
+ try {
96
+ return JSON.parse(await readFile(filePath, "utf8"));
97
+ } catch (error) {
98
+ if (error.code === "ENOENT" && fallback !== undefined) {
99
+ return fallback;
100
+ }
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ main(process.argv.slice(2))
106
+ .then((exitCode) => {
107
+ process.exitCode = exitCode;
108
+ })
109
+ .catch((error) => {
110
+ process.stderr.write(`boundaries: ${error.message}\n`);
111
+ process.exitCode = 1;
112
+ });
package/src/core.js ADDED
@@ -0,0 +1,235 @@
1
+ const DEFAULT_ROOT_BOUNDARY_TAGS = {
2
+ "type:app": {
3
+ dependencies: {
4
+ deny: ["type:app"],
5
+ },
6
+ },
7
+ "type:package": {
8
+ dependencies: {
9
+ deny: ["type:app"],
10
+ },
11
+ },
12
+ "type:tooling": {
13
+ dependencies: {
14
+ deny: ["type:app"],
15
+ },
16
+ },
17
+ };
18
+
19
+ export function inferTagsForWorkspace(workspace) {
20
+ const normalizedPath = normalizePath(workspace.path);
21
+ const packageName = workspace.name ?? "";
22
+ const scope = inferScope(normalizedPath, packageName);
23
+ const type = inferType(normalizedPath);
24
+ const visibility = workspace.packageJson?.private === false ? "public" : "internal";
25
+
26
+ return [`type:${type}`, `scope:${scope}`, `visibility:${visibility}`];
27
+ }
28
+
29
+ export function createPackageTurboConfig(currentConfig = {}, tags) {
30
+ return {
31
+ ...currentConfig,
32
+ extends: ensureRootExtends(currentConfig.extends),
33
+ tags: unique(tags),
34
+ };
35
+ }
36
+
37
+ export function applyRootBoundaryConfig(currentConfig = {}) {
38
+ return {
39
+ ...currentConfig,
40
+ boundaries: {
41
+ ...(currentConfig.boundaries ?? {}),
42
+ tags: mergeBoundaryTags(
43
+ DEFAULT_ROOT_BOUNDARY_TAGS,
44
+ currentConfig.boundaries?.tags ?? {},
45
+ ),
46
+ },
47
+ };
48
+ }
49
+
50
+ export function evaluateDependency({
51
+ rootConfig,
52
+ fromTags,
53
+ toTags,
54
+ fromName,
55
+ toName,
56
+ }) {
57
+ const tagRules = rootConfig.boundaries?.tags ?? {};
58
+ const targetSelectors = new Set([...toTags, toName].filter(Boolean));
59
+ const sourceSelectors = new Set([...fromTags, fromName].filter(Boolean));
60
+
61
+ for (const fromTag of fromTags) {
62
+ const dependencyDecision = evaluateRuleSet({
63
+ ruleSet: tagRules[fromTag]?.dependencies,
64
+ candidateSelectors: targetSelectors,
65
+ direction: "dependency",
66
+ activeTag: fromTag,
67
+ });
68
+
69
+ if (!dependencyDecision.allowed) {
70
+ return dependencyDecision;
71
+ }
72
+ }
73
+
74
+ for (const toTag of toTags) {
75
+ const dependentDecision = evaluateRuleSet({
76
+ ruleSet: tagRules[toTag]?.dependents,
77
+ candidateSelectors: sourceSelectors,
78
+ direction: "dependent",
79
+ activeTag: toTag,
80
+ });
81
+
82
+ if (!dependentDecision.allowed) {
83
+ return dependentDecision;
84
+ }
85
+ }
86
+
87
+ return { allowed: true, reason: "No boundary rule blocks this dependency." };
88
+ }
89
+
90
+ function evaluateRuleSet({ ruleSet, candidateSelectors, direction, activeTag }) {
91
+ if (!ruleSet) {
92
+ return { allowed: true };
93
+ }
94
+
95
+ const denied = ruleSet.deny?.find((selector) => candidateSelectors.has(selector));
96
+ if (denied) {
97
+ return {
98
+ allowed: false,
99
+ reason: `${activeTag} denies ${direction} selector ${denied}.`,
100
+ };
101
+ }
102
+
103
+ if (ruleSet.allow?.length > 0) {
104
+ const allowed = ruleSet.allow.some((selector) => candidateSelectors.has(selector));
105
+ if (!allowed) {
106
+ return {
107
+ allowed: false,
108
+ reason: `${activeTag} only allows ${direction} selectors: ${ruleSet.allow.join(", ")}.`,
109
+ };
110
+ }
111
+ }
112
+
113
+ return { allowed: true };
114
+ }
115
+
116
+ function mergeBoundaryTags(defaultTags, existingTags) {
117
+ const merged = { ...defaultTags, ...existingTags };
118
+
119
+ for (const tag of Object.keys(defaultTags)) {
120
+ merged[tag] = mergeBoundaryTag(defaultTags[tag], existingTags[tag] ?? {});
121
+ }
122
+
123
+ return merged;
124
+ }
125
+
126
+ function mergeBoundaryTag(defaultTag, existingTag) {
127
+ const merged = {
128
+ ...defaultTag,
129
+ ...existingTag,
130
+ dependencies: mergeRelationship(
131
+ defaultTag.dependencies,
132
+ existingTag.dependencies,
133
+ ),
134
+ dependents: mergeRelationship(defaultTag.dependents, existingTag.dependents),
135
+ };
136
+
137
+ if (!merged.dependencies) {
138
+ delete merged.dependencies;
139
+ }
140
+
141
+ if (!merged.dependents) {
142
+ delete merged.dependents;
143
+ }
144
+
145
+ return merged;
146
+ }
147
+
148
+ function mergeRelationship(defaultRelationship, existingRelationship) {
149
+ if (!defaultRelationship && !existingRelationship) {
150
+ return undefined;
151
+ }
152
+
153
+ const merged = {
154
+ ...(defaultRelationship ?? {}),
155
+ ...(existingRelationship ?? {}),
156
+ };
157
+
158
+ const allow = unique([
159
+ ...(defaultRelationship?.allow ?? []),
160
+ ...(existingRelationship?.allow ?? []),
161
+ ]);
162
+ const deny = unique([
163
+ ...(defaultRelationship?.deny ?? []),
164
+ ...(existingRelationship?.deny ?? []),
165
+ ]);
166
+
167
+ if (allow.length > 0) {
168
+ merged.allow = allow;
169
+ } else {
170
+ delete merged.allow;
171
+ }
172
+
173
+ if (deny.length > 0) {
174
+ merged.deny = deny;
175
+ } else {
176
+ delete merged.deny;
177
+ }
178
+
179
+ return merged;
180
+ }
181
+
182
+ function inferType(workspacePath) {
183
+ if (workspacePath.startsWith("apps/") || workspacePath === "apps") {
184
+ return "app";
185
+ }
186
+
187
+ if (
188
+ workspacePath.startsWith("tooling/") ||
189
+ workspacePath.startsWith("tools/") ||
190
+ workspacePath.includes("/eslint") ||
191
+ workspacePath.includes("/lint") ||
192
+ workspacePath.includes("/config")
193
+ ) {
194
+ return "tooling";
195
+ }
196
+
197
+ return "package";
198
+ }
199
+
200
+ function inferScope(workspacePath, packageName) {
201
+ const pathParts = workspacePath.split("/").filter(Boolean);
202
+ if (pathParts.length > 1) {
203
+ return sanitizeTagValue(pathParts.at(-1));
204
+ }
205
+
206
+ const unscopedName = packageName.includes("/")
207
+ ? packageName.split("/").at(-1)
208
+ : packageName;
209
+
210
+ return sanitizeTagValue(unscopedName || pathParts.at(-1) || "unknown");
211
+ }
212
+
213
+ function ensureRootExtends(extendsValue) {
214
+ if (!Array.isArray(extendsValue) || extendsValue.length === 0) {
215
+ return ["//"];
216
+ }
217
+
218
+ if (extendsValue[0] === "//") {
219
+ return extendsValue;
220
+ }
221
+
222
+ return ["//", ...extendsValue.filter((value) => value !== "//")];
223
+ }
224
+
225
+ function normalizePath(value) {
226
+ return value.replaceAll("\\", "/").replace(/^\.?\//, "").replace(/\/$/, "");
227
+ }
228
+
229
+ function sanitizeTagValue(value) {
230
+ return value.toLowerCase().replace(/^@/, "").replaceAll(/[^a-z0-9._-]+/g, "-");
231
+ }
232
+
233
+ function unique(values) {
234
+ return [...new Set(values.filter((value) => value !== undefined))];
235
+ }
package/src/init.js ADDED
@@ -0,0 +1,164 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import {
5
+ applyRootBoundaryConfig,
6
+ createPackageTurboConfig,
7
+ inferTagsForWorkspace,
8
+ } from "./core.js";
9
+
10
+ export async function initRepository({ root = process.cwd() } = {}) {
11
+ const rootPackageJsonPath = join(root, "package.json");
12
+ const rootTurboJsonPath = join(root, "turbo.json");
13
+ const rootPackageJson = await readJson(rootPackageJsonPath);
14
+ const rootTurboJson = await readJson(rootTurboJsonPath, {});
15
+ const workspaces = await discoverWorkspaces(root, rootPackageJson);
16
+
17
+ rootPackageJson.scripts = {
18
+ ...(rootPackageJson.scripts ?? {}),
19
+ boundaries: rootPackageJson.scripts?.boundaries ?? "boundaries check",
20
+ };
21
+
22
+ await writeJson(rootPackageJsonPath, rootPackageJson);
23
+ await writeJson(rootTurboJsonPath, applyRootBoundaryConfig(rootTurboJson));
24
+
25
+ for (const workspace of workspaces) {
26
+ const turboJsonPath = join(root, workspace.path, "turbo.json");
27
+ const currentTurboJson = await readJson(turboJsonPath, {});
28
+ const tags = inferTagsForWorkspace(workspace);
29
+ await writeJson(turboJsonPath, createPackageTurboConfig(currentTurboJson, tags));
30
+ }
31
+
32
+ return { workspaces };
33
+ }
34
+
35
+ export async function discoverWorkspaces(root, rootPackageJson = undefined) {
36
+ const packageJson = rootPackageJson ?? (await readJson(join(root, "package.json")));
37
+ const patterns = await workspacePatterns(root, packageJson);
38
+ const workspaces = [];
39
+
40
+ for (const pattern of patterns) {
41
+ workspaces.push(...(await discoverPattern(root, pattern)));
42
+ }
43
+
44
+ return workspaces
45
+ .sort((left, right) => left.path.localeCompare(right.path))
46
+ .filter((workspace, index, allWorkspaces) => {
47
+ return allWorkspaces.findIndex((candidate) => candidate.path === workspace.path) === index;
48
+ });
49
+ }
50
+
51
+ async function workspacePatterns(root, packageJson) {
52
+ if (Array.isArray(packageJson.workspaces)) {
53
+ return packageJson.workspaces;
54
+ }
55
+
56
+ if (Array.isArray(packageJson.workspaces?.packages)) {
57
+ return packageJson.workspaces.packages;
58
+ }
59
+
60
+ const pnpmWorkspace = await readText(join(root, "pnpm-workspace.yaml"), null);
61
+ if (pnpmWorkspace) {
62
+ const patterns = parsePnpmWorkspacePackages(pnpmWorkspace);
63
+ if (patterns.length > 0) {
64
+ return patterns;
65
+ }
66
+ }
67
+
68
+ return ["apps/*", "packages/*"];
69
+ }
70
+
71
+ function parsePnpmWorkspacePackages(contents) {
72
+ const patterns = [];
73
+ let inPackages = false;
74
+
75
+ for (const rawLine of contents.split("\n")) {
76
+ const line = rawLine.replace(/\s+#.*$/, "");
77
+ if (/^packages:\s*$/.test(line)) {
78
+ inPackages = true;
79
+ continue;
80
+ }
81
+
82
+ if (inPackages && /^\S/.test(line) && !line.startsWith("packages:")) {
83
+ inPackages = false;
84
+ }
85
+
86
+ if (!inPackages) {
87
+ continue;
88
+ }
89
+
90
+ const match = line.match(/^\s*-\s+["']?([^"']+)["']?\s*$/);
91
+ if (match && !match[1].startsWith("!")) {
92
+ patterns.push(match[1]);
93
+ }
94
+ }
95
+
96
+ return patterns;
97
+ }
98
+
99
+ async function discoverPattern(root, pattern) {
100
+ if (!pattern.endsWith("/*")) {
101
+ return [];
102
+ }
103
+
104
+ const parentPath = pattern.slice(0, -2);
105
+ const absoluteParentPath = join(root, parentPath);
106
+ let entries;
107
+
108
+ try {
109
+ entries = await readdir(absoluteParentPath, { withFileTypes: true });
110
+ } catch (error) {
111
+ if (error.code === "ENOENT") {
112
+ return [];
113
+ }
114
+ throw error;
115
+ }
116
+
117
+ const workspaces = [];
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory()) {
120
+ continue;
121
+ }
122
+
123
+ const workspacePath = `${parentPath}/${entry.name}`;
124
+ const packageJson = await readJson(join(root, workspacePath, "package.json"), null);
125
+ if (!packageJson) {
126
+ continue;
127
+ }
128
+
129
+ workspaces.push({
130
+ name: packageJson.name,
131
+ packageJson,
132
+ path: workspacePath,
133
+ });
134
+ }
135
+
136
+ return workspaces;
137
+ }
138
+
139
+ async function readJson(filePath, fallback = undefined) {
140
+ try {
141
+ return JSON.parse(await readFile(filePath, "utf8"));
142
+ } catch (error) {
143
+ if (error.code === "ENOENT" && fallback !== undefined) {
144
+ return fallback;
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ async function readText(filePath, fallback = undefined) {
151
+ try {
152
+ return await readFile(filePath, "utf8");
153
+ } catch (error) {
154
+ if (error.code === "ENOENT" && fallback !== undefined) {
155
+ return fallback;
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ async function writeJson(filePath, value) {
162
+ await mkdir(dirname(filePath), { recursive: true });
163
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
164
+ }