@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 +79 -0
- package/package.json +21 -0
- package/skills/howells-boundaries/SKILL.md +60 -0
- package/skills/howells-boundaries/agents/openai.yaml +7 -0
- package/src/check.js +85 -0
- package/src/cli.js +112 -0
- package/src/core.js +235 -0
- package/src/init.js +164 -0
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
|
+
}
|