@decohere/forge 0.0.1-dev.121 → 0.0.1-dev.123

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026 Typesmiths
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -136,6 +136,15 @@ export default {
136
136
 
137
137
  Design record: [ADR 0006](../../docs/adr/0006-forge-package-deployables.md).
138
138
 
139
+ ## `forge dependencies`: package manifests from imports
140
+
141
+ There is one install, from the root `package.json`, and it owns every version. `forge dependencies sync` writes each package's `dependencies` and `devDependencies` from what its code imports, using the root's ranges. `forge dependencies report --fail-on-drift` fails when a manifest differs from what sync would write; CI runs it.
142
+
143
+ - **Peers** are declared by name in the package's own `peerDependencies`. Sync sets the root's range and keeps them out of the other two lists, even when no code imports them. Mark optional ones in `peerDependenciesMeta` by hand.
144
+ - **Under the release configuration**, which scans only shipped code, importing a package the root lists only as a devDependency is an error unless the package declares it a peer.
145
+
146
+ Design record: [ADR 0017](../../docs/adr/0017-dependency-manifests-from-imports.md).
147
+
139
148
  ## `forge link` clears `dist`
140
149
 
141
150
  `forge link` is a development tool: it symlinks build output into a consumer's `node_modules`. Like `forge build`, it cleans `dist` first. Without `--build` it then links an output folder holding only `package.json` files, so run it with `--build` (or build again afterwards). It is not a way to make a release build runnable; use `forge package` for that.
@@ -51,11 +51,30 @@ class Copy {
51
51
  cwd
52
52
  });
53
53
  await Promise.all(rootFiles.map((file)=>this.copyFileToOut(file, root)));
54
+ await this.copyWorkspaceLicense(proj, rootFiles, root);
54
55
  // Configuration-only globs → each pack target outDir (next to emitted JS).
55
56
  await this.copyColocatedPackAssets(proj, cwd);
56
57
  }));
57
58
  return _cli.ActionResponse.Success(Copy.name);
58
59
  };
60
+ /**
61
+ * A package without a LICENSE of its own ships the workspace's, so a monorepo
62
+ * licensed once at the root still puts the license text in every tarball.
63
+ */ async copyWorkspaceLicense(proj, rootFiles, outDir) {
64
+ if (!proj.configuration.pack) {
65
+ return;
66
+ }
67
+ if (rootFiles.some((file)=>/^LICEN[CS]E/i.test(file.relativePath))) {
68
+ return;
69
+ }
70
+ const licenses = await (0, _io1.findFiles)([
71
+ "LICENSE*",
72
+ "LICENCE*"
73
+ ], {
74
+ cwd: this.workspace.rootDir
75
+ });
76
+ await Promise.all(licenses.map((file)=>this.copyFileToOut(file, outDir)));
77
+ }
59
78
  /**
60
79
  * Packed builds emit JS under `<out>/<target>/` (e.g. `es6/`). Assets listed on
61
80
  * the *configuration* `copyFiles` (not the workspace defaults) are also copied
@@ -21,24 +21,22 @@ function _ts_decorate(decorators, target, key, desc) {
21
21
  }
22
22
  class DependencyResolver {
23
23
  computeDrift(current, computed) {
24
- const sortKeys = (obj)=>Object.fromEntries(Object.entries(obj).sort());
25
- const currentDeps = sortKeys(current.dependencies ?? {});
26
- const computedDeps = sortKeys(computed.dependencies ?? {});
27
- const currentDevDeps = sortKeys(current.devDependencies ?? {});
28
- const computedDevDeps = sortKeys(computed.devDependencies ?? {});
29
- const hasDrift = JSON.stringify(currentDeps) !== JSON.stringify(computedDeps) || JSON.stringify(currentDevDeps) !== JSON.stringify(computedDevDeps);
30
- if (!hasDrift) {
24
+ const lists = (pkg)=>{
25
+ const sortKeys = (obj)=>Object.fromEntries(Object.entries(obj ?? {}).sort());
26
+ return {
27
+ dependencies: sortKeys(pkg.dependencies),
28
+ devDependencies: sortKeys(pkg.devDependencies),
29
+ peerDependencies: sortKeys(pkg.peerDependencies)
30
+ };
31
+ };
32
+ const original = lists(current);
33
+ const updated = lists(computed);
34
+ if (JSON.stringify(original) === JSON.stringify(updated)) {
31
35
  return null;
32
36
  }
33
37
  return {
34
- original: {
35
- dependencies: currentDeps,
36
- devDependencies: currentDevDeps
37
- },
38
- updated: {
39
- dependencies: computedDeps,
40
- devDependencies: computedDevDeps
41
- }
38
+ original,
39
+ updated
42
40
  };
43
41
  }
44
42
  async getWorkspacePackages(workspace, projects) {
@@ -78,29 +76,77 @@ class DependencyResolver {
78
76
  ]);
79
77
  }
80
78
  async computeProjectDependencies(project, workspacePackages) {
81
- const { dependencies, devDependencies, warnings } = await this.getDependencies(project, workspacePackages);
82
79
  const pkg = await project.getPackageJson();
83
- const updatedPackage = pkg.withDependencies(dependencies).withDevDependencies(devDependencies);
80
+ const peerNames = Object.keys(pkg.peerDependencies ?? {});
81
+ const peerDependencies = this.getPeerDependencies(project, peerNames, workspacePackages);
82
+ const { dependencies, devDependencies, warnings } = await this.getDependencies(project, workspacePackages, new Set(peerNames));
83
+ let updatedPackage = pkg.withDependencies(dependencies).withDevDependencies(devDependencies);
84
+ if (peerNames.length > 0) {
85
+ updatedPackage = updatedPackage.withPeerDependencies(peerDependencies);
86
+ }
84
87
  return {
85
88
  project,
86
89
  updatedPackage,
87
90
  warnings
88
91
  };
89
92
  }
90
- async getDependencies(project, workspacePackages) {
93
+ /**
94
+ * A package declares its peers by name in its own `peerDependencies` (and marks optional ones in
95
+ * `peerDependenciesMeta`, which sync leaves as written). The root owns every version, so each
96
+ * peer gets the root's range; a peer the root doesn't list is an error. A declared peer stays
97
+ * even when no code imports it: a pino transport, say, is loaded by name.
98
+ */ getPeerDependencies(project, peerNames, workspacePackages) {
99
+ const peers = {};
100
+ for (const name of [
101
+ ...peerNames
102
+ ].sort()){
103
+ const version = workspacePackages.get(name)?.version;
104
+ if (version == null) {
105
+ throw new _errors.ForgeError(`"${name}" is declared a peer of ${project.name}, but the root package.json doesn't list it. ` + "Add it there: the root owns every version.");
106
+ }
107
+ peers[name] = version;
108
+ }
109
+ return peers;
110
+ }
111
+ async getDependencies(project, workspacePackages, peers) {
91
112
  const files = await project.getAllFiles();
92
- const results = await Promise.all(files.map((file)=>this.getFileImports(file)));
93
- const imports = results.flatMap((r)=>r.imports);
113
+ const results = await Promise.all(files.map(async (file)=>({
114
+ file,
115
+ ...await this.getFileImports(file)
116
+ })));
94
117
  const warnings = results.flatMap((r)=>r.warnings);
95
- const allDependencies = [
96
- ...new Set(imports)
97
- ].map((fileImport)=>this.getDependencyFrom(fileImport, workspacePackages)).filter((dep)=>dep != null);
118
+ /** Which files import each package, for errors that point at the code. */ const importedBy = new Map();
119
+ const allDependencies = [];
120
+ for (const { file, imports } of results){
121
+ for (const fileImport of imports){
122
+ const dep = this.getDependencyFrom(fileImport, workspacePackages);
123
+ if (dep == null || peers.has(dep.name)) {
124
+ continue;
125
+ }
126
+ if (!importedBy.has(dep.name)) {
127
+ importedBy.set(dep.name, new Set());
128
+ allDependencies.push(dep);
129
+ }
130
+ importedBy.get(dep.name)?.add(file.relativePath);
131
+ }
132
+ }
98
133
  const missing = allDependencies.filter((dep)=>dep.missing);
99
134
  if (missing.length > 0) {
100
135
  const names = missing.map((dep)=>dep.name).join(", ");
101
136
  throw new _errors.ForgeError(`Missing packages in project "${project.name}": ${names}`);
102
137
  }
103
138
  const resolved = allDependencies.filter((dep)=>!dep.missing);
139
+ // A configuration that packs is the published shape: its files ship, so a package they
140
+ // import that the root keeps for development would be missing for the consumer.
141
+ if (project.configuration.pack === true) {
142
+ const devOnly = resolved.filter((dep)=>dep.isDev && !dep.internal);
143
+ if (devOnly.length > 0) {
144
+ const lines = devOnly.map((dep)=>` - ${dep.name}, imported by ${[
145
+ ...importedBy.get(dep.name) ?? []
146
+ ].sort().join(", ")}`);
147
+ throw new _errors.ForgeError(`${project.name} ships code that imports packages the root lists only as a devDependency:\n` + `${lines.join("\n")}\n` + "Move each to the root's dependencies, or, if the consumer provides it, " + `declare it in ${project.name}'s peerDependencies.`);
148
+ }
149
+ }
104
150
  const dependencies = Object.fromEntries(resolved.filter((dep)=>!dep.isDev).map((dep)=>[
105
151
  dep.name,
106
152
  dep.version
@@ -36,11 +36,30 @@ export class Copy {
36
36
  cwd
37
37
  });
38
38
  await Promise.all(rootFiles.map((file)=>this.copyFileToOut(file, root)));
39
+ await this.copyWorkspaceLicense(proj, rootFiles, root);
39
40
  // Configuration-only globs → each pack target outDir (next to emitted JS).
40
41
  await this.copyColocatedPackAssets(proj, cwd);
41
42
  }));
42
43
  return ActionResponse.Success(Copy.name);
43
44
  };
45
+ /**
46
+ * A package without a LICENSE of its own ships the workspace's, so a monorepo
47
+ * licensed once at the root still puts the license text in every tarball.
48
+ */ async copyWorkspaceLicense(proj, rootFiles, outDir) {
49
+ if (!proj.configuration.pack) {
50
+ return;
51
+ }
52
+ if (rootFiles.some((file)=>/^LICEN[CS]E/i.test(file.relativePath))) {
53
+ return;
54
+ }
55
+ const licenses = await findFiles([
56
+ "LICENSE*",
57
+ "LICENCE*"
58
+ ], {
59
+ cwd: this.workspace.rootDir
60
+ });
61
+ await Promise.all(licenses.map((file)=>this.copyFileToOut(file, outDir)));
62
+ }
44
63
  /**
45
64
  * Packed builds emit JS under `<out>/<target>/` (e.g. `es6/`). Assets listed on
46
65
  * the *configuration* `copyFiles` (not the workspace defaults) are also copied
@@ -11,24 +11,22 @@ import { BUILT_IN_MODULES } from "../const.js";
11
11
  import { ForgeError } from "../errors.js";
12
12
  export class DependencyResolver {
13
13
  computeDrift(current, computed) {
14
- const sortKeys = (obj)=>Object.fromEntries(Object.entries(obj).sort());
15
- const currentDeps = sortKeys(current.dependencies ?? {});
16
- const computedDeps = sortKeys(computed.dependencies ?? {});
17
- const currentDevDeps = sortKeys(current.devDependencies ?? {});
18
- const computedDevDeps = sortKeys(computed.devDependencies ?? {});
19
- const hasDrift = JSON.stringify(currentDeps) !== JSON.stringify(computedDeps) || JSON.stringify(currentDevDeps) !== JSON.stringify(computedDevDeps);
20
- if (!hasDrift) {
14
+ const lists = (pkg)=>{
15
+ const sortKeys = (obj)=>Object.fromEntries(Object.entries(obj ?? {}).sort());
16
+ return {
17
+ dependencies: sortKeys(pkg.dependencies),
18
+ devDependencies: sortKeys(pkg.devDependencies),
19
+ peerDependencies: sortKeys(pkg.peerDependencies)
20
+ };
21
+ };
22
+ const original = lists(current);
23
+ const updated = lists(computed);
24
+ if (JSON.stringify(original) === JSON.stringify(updated)) {
21
25
  return null;
22
26
  }
23
27
  return {
24
- original: {
25
- dependencies: currentDeps,
26
- devDependencies: currentDevDeps
27
- },
28
- updated: {
29
- dependencies: computedDeps,
30
- devDependencies: computedDevDeps
31
- }
28
+ original,
29
+ updated
32
30
  };
33
31
  }
34
32
  async getWorkspacePackages(workspace, projects) {
@@ -68,29 +66,77 @@ export class DependencyResolver {
68
66
  ]);
69
67
  }
70
68
  async computeProjectDependencies(project, workspacePackages) {
71
- const { dependencies, devDependencies, warnings } = await this.getDependencies(project, workspacePackages);
72
69
  const pkg = await project.getPackageJson();
73
- const updatedPackage = pkg.withDependencies(dependencies).withDevDependencies(devDependencies);
70
+ const peerNames = Object.keys(pkg.peerDependencies ?? {});
71
+ const peerDependencies = this.getPeerDependencies(project, peerNames, workspacePackages);
72
+ const { dependencies, devDependencies, warnings } = await this.getDependencies(project, workspacePackages, new Set(peerNames));
73
+ let updatedPackage = pkg.withDependencies(dependencies).withDevDependencies(devDependencies);
74
+ if (peerNames.length > 0) {
75
+ updatedPackage = updatedPackage.withPeerDependencies(peerDependencies);
76
+ }
74
77
  return {
75
78
  project,
76
79
  updatedPackage,
77
80
  warnings
78
81
  };
79
82
  }
80
- async getDependencies(project, workspacePackages) {
83
+ /**
84
+ * A package declares its peers by name in its own `peerDependencies` (and marks optional ones in
85
+ * `peerDependenciesMeta`, which sync leaves as written). The root owns every version, so each
86
+ * peer gets the root's range; a peer the root doesn't list is an error. A declared peer stays
87
+ * even when no code imports it: a pino transport, say, is loaded by name.
88
+ */ getPeerDependencies(project, peerNames, workspacePackages) {
89
+ const peers = {};
90
+ for (const name of [
91
+ ...peerNames
92
+ ].sort()){
93
+ const version = workspacePackages.get(name)?.version;
94
+ if (version == null) {
95
+ throw new ForgeError(`"${name}" is declared a peer of ${project.name}, but the root package.json doesn't list it. ` + "Add it there: the root owns every version.");
96
+ }
97
+ peers[name] = version;
98
+ }
99
+ return peers;
100
+ }
101
+ async getDependencies(project, workspacePackages, peers) {
81
102
  const files = await project.getAllFiles();
82
- const results = await Promise.all(files.map((file)=>this.getFileImports(file)));
83
- const imports = results.flatMap((r)=>r.imports);
103
+ const results = await Promise.all(files.map(async (file)=>({
104
+ file,
105
+ ...await this.getFileImports(file)
106
+ })));
84
107
  const warnings = results.flatMap((r)=>r.warnings);
85
- const allDependencies = [
86
- ...new Set(imports)
87
- ].map((fileImport)=>this.getDependencyFrom(fileImport, workspacePackages)).filter((dep)=>dep != null);
108
+ /** Which files import each package, for errors that point at the code. */ const importedBy = new Map();
109
+ const allDependencies = [];
110
+ for (const { file, imports } of results){
111
+ for (const fileImport of imports){
112
+ const dep = this.getDependencyFrom(fileImport, workspacePackages);
113
+ if (dep == null || peers.has(dep.name)) {
114
+ continue;
115
+ }
116
+ if (!importedBy.has(dep.name)) {
117
+ importedBy.set(dep.name, new Set());
118
+ allDependencies.push(dep);
119
+ }
120
+ importedBy.get(dep.name)?.add(file.relativePath);
121
+ }
122
+ }
88
123
  const missing = allDependencies.filter((dep)=>dep.missing);
89
124
  if (missing.length > 0) {
90
125
  const names = missing.map((dep)=>dep.name).join(", ");
91
126
  throw new ForgeError(`Missing packages in project "${project.name}": ${names}`);
92
127
  }
93
128
  const resolved = allDependencies.filter((dep)=>!dep.missing);
129
+ // A configuration that packs is the published shape: its files ship, so a package they
130
+ // import that the root keeps for development would be missing for the consumer.
131
+ if (project.configuration.pack === true) {
132
+ const devOnly = resolved.filter((dep)=>dep.isDev && !dep.internal);
133
+ if (devOnly.length > 0) {
134
+ const lines = devOnly.map((dep)=>` - ${dep.name}, imported by ${[
135
+ ...importedBy.get(dep.name) ?? []
136
+ ].sort().join(", ")}`);
137
+ throw new ForgeError(`${project.name} ships code that imports packages the root lists only as a devDependency:\n` + `${lines.join("\n")}\n` + "Move each to the root's dependencies, or, if the consumer provides it, " + `declare it in ${project.name}'s peerDependencies.`);
138
+ }
139
+ }
94
140
  const dependencies = Object.fromEntries(resolved.filter((dep)=>!dep.isDev).map((dep)=>[
95
141
  dep.name,
96
142
  dep.version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/forge",
3
- "version": "0.0.1-dev.121",
3
+ "version": "0.0.1-dev.123",
4
4
  "description": "TypeScript monorepo build system — zero config, fast compilation, streamlined developer workflow",
5
5
  "keywords": [
6
6
  "typescript",
@@ -10,6 +10,15 @@
10
10
  "swc",
11
11
  "decohere"
12
12
  ],
13
+ "homepage": "https://github.com/typesmiths/decohere/tree/main/projects/forge#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/typesmiths/decohere/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/typesmiths/decohere.git",
20
+ "directory": "projects/forge"
21
+ },
13
22
  "license": "ISC",
14
23
  "author": "Typesmiths",
15
24
  "type": "module",
@@ -29,11 +38,11 @@
29
38
  "test": "echo \"Error: no test specified\" && exit 1"
30
39
  },
31
40
  "dependencies": {
32
- "@decohere/core": "0.0.1-dev.121",
33
- "@decohere/crucible": "0.0.1-dev.121",
34
- "@decohere/devkit": "0.0.1-dev.121",
35
- "@decohere/scribe": "0.0.1-dev.121",
36
- "@decohere/workspace": "0.0.1-dev.121",
41
+ "@decohere/core": "0.0.1-dev.123",
42
+ "@decohere/crucible": "0.0.1-dev.123",
43
+ "@decohere/devkit": "0.0.1-dev.123",
44
+ "@decohere/scribe": "0.0.1-dev.123",
45
+ "@decohere/workspace": "0.0.1-dev.123",
37
46
  "@swc/core": "^1.11.11",
38
47
  "async": "^3.2.6",
39
48
  "parse-imports": "^2.2.1",
@@ -10,6 +10,11 @@ export declare class Copy implements Action<CopyOptions> {
10
10
  private readonly workspace;
11
11
  constructor(workspace: Workspace);
12
12
  run: (options: CopyOptions) => Promise<ActionResponse>;
13
+ /**
14
+ * A package without a LICENSE of its own ships the workspace's, so a monorepo
15
+ * licensed once at the root still puts the license text in every tarball.
16
+ */
17
+ private copyWorkspaceLicense;
13
18
  /**
14
19
  * Packed builds emit JS under `<out>/<target>/` (e.g. `es6/`). Assets listed on
15
20
  * the *configuration* `copyFiles` (not the workspace defaults) are also copied
@@ -9,6 +9,13 @@ export declare class DependencyResolver {
9
9
  computeDrift(current: PackageJson, computed: PackageJson): DriftEntry | null;
10
10
  getWorkspacePackages(workspace: Workspace, projects: readonly Project[]): Promise<Map<string, Package>>;
11
11
  computeProjectDependencies(project: Project, workspacePackages: Map<string, Package>): Promise<ProjectDependencies>;
12
+ /**
13
+ * A package declares its peers by name in its own `peerDependencies` (and marks optional ones in
14
+ * `peerDependenciesMeta`, which sync leaves as written). The root owns every version, so each
15
+ * peer gets the root's range; a peer the root doesn't list is an error. A declared peer stays
16
+ * even when no code imports it: a pino transport, say, is loaded by name.
17
+ */
18
+ private getPeerDependencies;
12
19
  private getDependencies;
13
20
  private getDependencyFrom;
14
21
  private getFileImports;
@@ -22,15 +22,14 @@ export type SyncprojectResult = ResultStatus & {
22
22
  projectName: string;
23
23
  drift?: DriftEntry | null;
24
24
  };
25
+ export type DependencyLists = {
26
+ dependencies: Record<string, string>;
27
+ devDependencies: Record<string, string>;
28
+ peerDependencies: Record<string, string>;
29
+ };
25
30
  export type DriftEntry = {
26
- original: {
27
- dependencies: Record<string, string>;
28
- devDependencies: Record<string, string>;
29
- };
30
- updated: {
31
- dependencies: Record<string, string>;
32
- devDependencies: Record<string, string>;
33
- };
31
+ original: DependencyLists;
32
+ updated: DependencyLists;
34
33
  };
35
34
  export type DependencyWarning = {
36
35
  file: string;