Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Check tsconfig refs
run: pnpm check:tsrefs

- name: Check cascade version bumps
run: pnpm versions

test-posix:
name: Test (${{ matrix.os }})
strategy:
Expand Down
118 changes: 118 additions & 0 deletions .internal/check-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { resolve } from "node:path";
import process from "node:process";
import { readTextFile, writeTextFile } from "@effectionx/fs";
import { x } from "@effectionx/tinyexec";
import { main } from "effection";
import { inc as semverInc } from "semver";
import { buildDepGraph, getTransitiveDependents } from "./lib/dep-graph.ts";

const mode = process.argv[2] ?? "check";

if (!["check", "sync"].includes(mode)) {
console.error("Usage: node .internal/check-versions.ts [check|sync]");
process.exit(1);
}

const isSyncMode = mode === "sync";

await main(function* () {
const graph = yield* buildDepGraph();

// Determine which packages have a version not yet published to npm.
const pendingRelease = new Set<string>();

for (const pkg of graph.packages.values()) {
const npmCheck = yield* x(
"npm",
["view", `${pkg.name}@${pkg.version}`, "version"],
{ throwOnError: false },
);
const result = yield* npmCheck;

if (result.exitCode !== 0 || result.stdout.trim() === "") {
pendingRelease.add(pkg.name);
}
}
Comment on lines +28 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import json
import pathlib
import subprocess

root = pathlib.Path(".")
entries = []

workspace_file = root / "pnpm-workspace.yaml"
for line in workspace_file.read_text().splitlines():
    trimmed = line.strip()
    if trimmed.startswith("-"):
        value = trimmed[1:].strip().strip('"').strip("'")
        if value:
            entries.append(value)

for workspace in entries:
    pkgfile = root / workspace / "package.json"
    if not pkgfile.exists():
        continue

    data = json.loads(pkgfile.read_text())
    if data.get("private"):
        continue

    name = data["name"]
    version = data["version"]

    pkg = subprocess.run(
        ["npm", "view", name, "version"],
        text=True,
        capture_output=True,
    )
    ver = subprocess.run(
        ["npm", "view", f"{name}@{version}", "version"],
        text=True,
        capture_output=True,
    )

    if pkg.returncode != 0 or ver.returncode != 0:
        print(f"{name}@{version}")
        print(f"  package rc={pkg.returncode} latest={pkg.stdout.strip()!r}")
        if pkg.stderr.strip():
            print(f"  package stderr={pkg.stderr.strip().splitlines()[-1]!r}")
        print(f"  version rc={ver.returncode} published={ver.stdout.strip()!r}")
        if ver.stderr.strip():
            print(f"  version stderr={ver.stderr.strip().splitlines()[-1]!r}")
PY

Repository: thefrontside/effectionx

Length of output: 49


🏁 Script executed:

cat -n .internal/check-versions.ts

Repository: thefrontside/effectionx

Length of output: 4469


🏁 Script executed:

head -60 .internal/publish-matrix.ts

Repository: thefrontside/effectionx

Length of output: 1719


🏁 Script executed:

rg -n "versions:sync|check-versions" --type ts

Repository: thefrontside/effectionx

Length of output: 49


Don't treat every failed npm view as "version not published" — distinguish package existence from transient failure.

The code conflates three distinct cases: version genuinely missing, package never published, and registry/auth/network failure. The last should hard-fail rather than cascade, but this design also loses the first-publish distinction already tracked in .internal/publish-matrix.ts:20-50. In versions:sync mode, a transient npm failure triggers version rewrites on unrelated packages (lines 66-88). Additionally, the cascade failure explanation (lines 101-110) can report empty trigger strings when conditions align.

Unlike publish-matrix.ts, which runs separate npm view ${name} and npm view ${name}@${version} checks to distinguish package existence, check-versions.ts runs only the version check. Any non-zero exit code becomes a false positive pending release.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/check-versions.ts around lines 28 - 39, The loop over
graph.packages currently treats any non-zero exit from the `npm view
${pkg.name}@${pkg.version}` call (npmCheck/result) as "version not published"
and adds pkg.name to pendingRelease; change this to distinguish three cases: (1)
run `npm view ${pkg.name}@${pkg.version}` and if it returns exitCode 0 with
stdout non-empty, do nothing; (2) if version-check exitCode !== 0, run a plain
`npm view ${pkg.name}` to see if the package exists—if that exists but version
missing, add to pendingRelease; (3) if the package-check itself failed with a
transient/network/auth error (non-zero exitCode or other error), surface a hard
failure (throw or exit) instead of adding to pendingRelease so `versions:sync`
cannot rewrite unrelated packages; mirror the existence/version split logic used
in publish-matrix.ts and use the same helper/command patterns around
npmCheck/result and pendingRelease to locate and update the behavior.


if (pendingRelease.size === 0) {
console.log(
"All package versions are already published. Nothing to check.",
);
return;
}

console.log(
`Pending releases: ${[...pendingRelease]
.map((name) => {
const pkg = graph.packages.get(name)!;
return `${name}@${pkg.version}`;
})
.join(", ")}`,
);

// Find all transitive dependents of the pending releases.
const requiredBumps = getTransitiveDependents(graph, pendingRelease);

// Remove packages that are already pending release — they're fine.
for (const name of pendingRelease) {
requiredBumps.delete(name);
}

if (requiredBumps.size === 0) {
console.log("All cascade version bumps are in order.");
return;
}

if (isSyncMode) {
// Apply patch bumps to all packages that need them.
const bumped: Array<{ name: string; from: string; to: string }> = [];

for (const name of requiredBumps) {
const pkg = graph.packages.get(name)!;
const newVersion = semverInc(pkg.version, "patch");
if (!newVersion) {
console.error(`Failed to increment version for ${name}@${pkg.version}`);
process.exit(1);
}

const packageJsonPath = resolve(pkg.workspacePath, "package.json");
const raw = yield* readTextFile(packageJsonPath);
const json = JSON.parse(raw) as Record<string, unknown>;
json.version = newVersion;
yield* writeTextFile(
packageJsonPath,
`${JSON.stringify(json, null, 2)}\n`,
);
Comment on lines +78 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

JSON serialization may alter field ordering and formatting.

JSON.stringify with 2-space indent is standard, but it won't preserve the original field order or any custom formatting (like trailing commas if present). This is generally acceptable for package.json, but be aware that diffs may show reordering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/check-versions.ts around lines 78 - 85, The current code reparses
and reserializes package.json which can reorder fields and change formatting;
instead update only the version field in the original raw text to preserve
ordering/formatting: locate the "version" property in the raw string (e.g.,
using a regex that matches the "version" key and its value while preserving
surrounding whitespace/commas), replace just the value with newVersion, and then
pass the modified raw string to writeTextFile; fall back to the existing
JSON.parse/JSON.stringify approach (using packageJsonPath, readTextFile,
writeTextFile, newVersion) only if the targeted text-replacement fails.


bumped.push({ name, from: pkg.version, to: newVersion });
}

console.log("\nBumped cascade versions:");
for (const { name, from, to } of bumped) {
console.log(` ${name} ${from} → ${to}`);
}
} else {
// Check mode: report missing bumps and fail.
console.error("\nMissing cascade version bumps:\n");

// Group by the dependency that triggered the cascade.
for (const name of requiredBumps) {
const pkg = graph.packages.get(name)!;
// Find which of its deps triggered this.
const triggers = pkg.publishedWorkspaceDeps.filter((dep) =>
pendingRelease.has(dep),
);
const triggerStr = triggers
.map((t) => {
const tp = graph.packages.get(t)!;
return `${t}@${tp.version}`;
})
.join(", ");

console.error(` ${name} (${pkg.version}) — depends on ${triggerStr}`);
Comment on lines +101 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve the trigger chain for transitive failures.

For multi-hop cascades, triggers only checks pendingRelease, so packages reached via another required bump can print depends on with no cause. That makes the check-mode output hard to act on for the exact transitive cases this tool is supposed to catch.

🛠️ Minimal fix
-      const triggers = pkg.publishedWorkspaceDeps.filter((dep) =>
-        pendingRelease.has(dep),
-      );
+      const triggers = pkg.publishedWorkspaceDeps.filter(
+        (dep) => pendingRelease.has(dep) || requiredBumps.has(dep),
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Group by the dependency that triggered the cascade.
for (const name of requiredBumps) {
const pkg = graph.packages.get(name)!;
// Find which of its deps triggered this.
const triggers = pkg.publishedWorkspaceDeps.filter((dep) =>
pendingRelease.has(dep),
);
const triggerStr = triggers
.map((t) => {
const tp = graph.packages.get(t)!;
return `${t}@${tp.version}`;
})
.join(", ");
console.error(` ${name} (${pkg.version}) — depends on ${triggerStr}`);
// Group by the dependency that triggered the cascade.
for (const name of requiredBumps) {
const pkg = graph.packages.get(name)!;
// Find which of its deps triggered this.
const triggers = pkg.publishedWorkspaceDeps.filter(
(dep) => pendingRelease.has(dep) || requiredBumps.has(dep),
);
const triggerStr = triggers
.map((t) => {
const tp = graph.packages.get(t)!;
return `${t}@${tp.version}`;
})
.join(", ");
console.error(` ${name} (${pkg.version}) — depends on ${triggerStr}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/check-versions.ts around lines 101 - 115, The current grouping
uses pkg.publishedWorkspaceDeps filtered only by pendingRelease, which loses the
transitive cause for packages in requiredBumps; change the trigger detection in
the loop over requiredBumps to walk dependency edges (starting from each dep in
pkg.publishedWorkspaceDeps) back to the nearest package that is in
pendingRelease (or is an original root in requiredBumps) and record that root
trigger so the printed triggerStr shows the actual triggering package and
version; update the logic around requiredBumps / pkg.publishedWorkspaceDeps /
pendingRelease (the block building triggers and triggerStr) to perform this
traversal and map each dependency to its ultimate triggering package before
formatting the output.

}

console.error("\nRun `pnpm versions:sync` to fix automatically.\n");
process.exit(1);
}
});
161 changes: 161 additions & 0 deletions .internal/lib/dep-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { resolve } from "node:path";
import process from "node:process";
import { readTextFile } from "@effectionx/fs";
import type { Operation } from "effection";

export interface WorkspacePackage {
/** Package name, e.g. "@effectionx/bdd". */
name: string;
/** Current version from package.json. */
version: string;
/** Workspace directory name, e.g. "bdd". */
workspace: string;
/** Absolute path to workspace directory. */
workspacePath: string;
/** True if the package is private. */
private: boolean;
/**
* Names of workspace packages listed in `dependencies` or
* `peerDependencies` with `workspace:*` protocol.
*/
publishedWorkspaceDeps: string[];
}

export interface DepGraph {
/** All non-private workspace packages indexed by name. */
packages: Map<string, WorkspacePackage>;
/**
* Reverse map: package name → names of packages that depend on it
* via published deps (`dependencies` or `peerDependencies`).
*/
dependents: Map<string, string[]>;
}

/**
* Build the published dependency graph for all workspace packages.
*
* Only `dependencies` and `peerDependencies` with `workspace:*` are
* considered because `devDependencies` are stripped at publish time.
*/
export function* buildDepGraph(): Operation<DepGraph> {
const rootDir = process.cwd();

const workspaceYaml = yield* readTextFile(
resolve(rootDir, "pnpm-workspace.yaml"),
);

const workspaces: string[] = [];
for (const line of workspaceYaml.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("-")) {
const value = trimmed.replace(/^-\s*/, "").replace(/^["']|["']$/g, "");
if (value) {
workspaces.push(value);
}
}
}

// Read all package.json files and build WorkspacePackage entries.
const allPackages: WorkspacePackage[] = [];

for (const workspace of workspaces) {
const workspacePath = resolve(rootDir, workspace);
const raw = yield* readTextFile(resolve(workspacePath, "package.json"));
const json = JSON.parse(raw) as Record<string, unknown>;
Comment on lines +63 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Avoid reading package.json twice for non-private packages.

The file is read once at line 63 during the initial workspace scan, then read again at line 90 for non-private packages. Store the parsed JSON in the first pass to avoid redundant I/O.

♻️ Suggested approach
   // Read all package.json files and build WorkspacePackage entries.
   const allPackages: WorkspacePackage[] = [];
+  const packageJsonCache = new Map<string, Record<string, unknown>>();

   for (const workspace of workspaces) {
     const workspacePath = resolve(rootDir, workspace);
     const raw = yield* readTextFile(resolve(workspacePath, "package.json"));
     const json = JSON.parse(raw) as Record<string, unknown>;

+    packageJsonCache.set(workspace, json);
     allPackages.push({
       name: json.name as string,
       // ...
     });
   }

   // Later, when resolving deps:
   for (const pkg of packages.values()) {
-    const raw = yield* readTextFile(resolve(pkg.workspacePath, "package.json"));
-    const json = JSON.parse(raw) as Record<string, unknown>;
+    const json = packageJsonCache.get(pkg.workspace)!;

     const deps = collectWorkspaceDeps(
       json.dependencies as Record<string, string> | undefined,
       packageNames,
     );

Also applies to: 89-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/lib/dep-graph.ts around lines 63 - 64, The code currently calls
readTextFile(resolve(workspacePath, "package.json")) twice (variables raw/json)
during the workspace scan and again for non-private packages; change the flow so
the initial parse (the json object produced after JSON.parse(raw) in the first
pass) is saved and reused instead of re-reading the file—e.g., store the parsed
object in a local variable scoped outside the condition or attach it to the
workspace record, then reference that parsed JSON when checking package privacy
and other fields (avoid a second readTextFile call around lines where
package.json is read again).


allPackages.push({
name: json.name as string,
version: json.version as string,
Comment on lines +66 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing validation for required name and version fields.

The code assumes json.name and json.version exist and are strings. A malformed package.json (or workspace pointing to a non-package directory) would cause subtle failures downstream. Add validation or use a schema library like zod (already a dependency per manifest).

🛡️ Suggested validation
+    if (typeof json.name !== "string" || !json.name) {
+      throw new Error(`Missing or invalid "name" in ${workspacePath}/package.json`);
+    }
+    if (typeof json.version !== "string" || !json.version) {
+      throw new Error(`Missing or invalid "version" in ${workspacePath}/package.json`);
+    }
+
     allPackages.push({
       name: json.name as string,
       version: json.version as string,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/lib/dep-graph.ts around lines 66 - 68, The code pushes package
entries assuming json.name and json.version are valid strings (see allPackages
push in dep-graph.ts), which can break on malformed package.json; add validation
before pushing: validate that json.name and json.version exist and are non-empty
strings (or use the existing zod dependency to define a schema and parse json),
and if validation fails skip the package or throw a clear error (include the
package path/context in the message) so downstream consumers of allPackages only
receive well-formed {name, version} entries.

workspace,
workspacePath,
private: Boolean(json.private),
publishedWorkspaceDeps: [], // filled below
});
}

// Index non-private packages by name.
const packages = new Map<string, WorkspacePackage>();
for (const pkg of allPackages) {
if (!pkg.private) {
packages.set(pkg.name, pkg);
}
}

const packageNames = new Set(packages.keys());

// Resolve published workspace deps and build reverse map.
const dependents = new Map<string, string[]>();

for (const pkg of packages.values()) {
const raw = yield* readTextFile(resolve(pkg.workspacePath, "package.json"));
const json = JSON.parse(raw) as Record<string, unknown>;

const deps = collectWorkspaceDeps(
json.dependencies as Record<string, string> | undefined,
packageNames,
);
const peerDeps = collectWorkspaceDeps(
json.peerDependencies as Record<string, string> | undefined,
packageNames,
);

const combined = [...new Set([...deps, ...peerDeps])].sort();
pkg.publishedWorkspaceDeps = combined;

for (const dep of combined) {
if (!dependents.has(dep)) {
dependents.set(dep, []);
}
dependents.get(dep)!.push(pkg.name);
}
}

return { packages, dependents };
}

/**
* Walk the dependency graph and return all transitive dependents of the
* given root package names.
*/
export function getTransitiveDependents(
graph: DepGraph,
roots: Iterable<string>,
): Set<string> {
const visited = new Set<string>();
const queue = [...roots];

while (queue.length > 0) {
const current = queue.pop()!;
const deps = graph.dependents.get(current) ?? [];
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep);
queue.push(dep);
}
}
}

return visited;
}

/** Extract workspace package names that use `workspace:*` protocol. */
function collectWorkspaceDeps(
depsObject: Record<string, string> | undefined,
packageNames: Set<string>,
): string[] {
if (!depsObject || typeof depsObject !== "object") {
return [];
}

const result: string[] = [];
for (const [name, range] of Object.entries(depsObject)) {
if (
packageNames.has(name) &&
typeof range === "string" &&
range.startsWith("workspace:")
) {
result.push(name);
}
}
Comment on lines +145 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import json
import pathlib

root = pathlib.Path(".")
entries = []

workspace_file = root / "pnpm-workspace.yaml"
for line in workspace_file.read_text().splitlines():
    trimmed = line.strip()
    if trimmed.startswith("-"):
        value = trimmed[1:].strip().strip('"').strip("'")
        if value:
            entries.append(value)

for workspace in entries:
    pkgfile = root / workspace / "package.json"
    if not pkgfile.exists():
        continue

    data = json.loads(pkgfile.read_text())
    for section in ("dependencies", "peerDependencies"):
        deps = data.get(section) or {}
        if not isinstance(deps, dict):
            continue
        for name, rng in deps.items():
            if isinstance(rng, str) and rng.startswith("workspace:") and rng != "workspace:*":
                print(f"{pkgfile}: {section}.{name} = {rng}")
PY

Repository: thefrontside/effectionx

Length of output: 49


Code doesn't match documented policy scope for workspace dependency cascade.

The startsWith("workspace:") predicate is broader than the documented workspace:* cascade rule (which covers workspace:^, workspace:~, and other variants). While the repository currently has no non-workspace:* workspace dependencies that would trigger unnecessary bumps, the code should be narrowed to align with the policy:

-      range.startsWith("workspace:")
+      range === "workspace:*"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.internal/lib/dep-graph.ts around lines 145 - 163, The predicate in
collectWorkspaceDeps is too broad (range.startsWith("workspace:")); narrow it to
only match the documented workspace cascade variants (e.g., workspace:*,
workspace:^, workspace:~) by replacing that check with a strict pattern test on
range (for example using a regex like /^workspace:(\*|~|\^)/) while keeping the
existing guards (depsObject, typeof range) and leaving packageNames and result
handling intact.

return result;
}
24 changes: 24 additions & 0 deletions .policies/version-bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,34 @@ Changed files:
Violation: Source code changed but version was not bumped.
```

## Cascade Rule

**When a package is bumped, all packages that list it as a published dependency
(`dependencies` or `peerDependencies` with `workspace:*`) must also be bumped.**

This ensures dependents are republished with the updated dependency range.
`devDependencies` are excluded because they are stripped at publish time.

### Tooling

| Command | Purpose |
|---------|---------|
| `pnpm versions` | **Check** — verifies all cascade bumps are present (runs in CI) |
| `pnpm versions:sync` | **Fix** — auto-applies patch bumps to dependents that need them |

### Example

If `@effectionx/test-adapter` is bumped from `0.7.3` to `0.7.4`:
- `@effectionx/bdd` depends on it via `"@effectionx/test-adapter": "workspace:*"`
- `@effectionx/bdd` must also be bumped (at minimum a patch bump)
- Run `pnpm versions:sync` to apply the cascade bumps automatically

## Verification Checklist

- [ ] If source files changed, `package.json` version was bumped
- [ ] Version bump type matches the change (major/minor/patch)
- [ ] Only one package version bumped per PR (unless changes span packages)
- [ ] Cascade bumps applied for all published dependents (`pnpm versions` passes)

## Common Mistakes

Expand All @@ -80,6 +103,7 @@ Violation: Source code changed but version was not bumped.
| Forgetting to bump version after bug fix | Add patch version bump to `package.json` |
| Using patch for new features | Use minor version bump instead |
| Bumping version for test-only changes | Remove unnecessary version bump |
| Forgetting to bump dependents after a dep changes | Run `pnpm versions:sync` |

## Related Policies

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"fmt:check": "biome format .",
"sync": "node --env-file=.env .internal/sync-tsrefs.ts",
"sync:fix": "node --env-file=.env .internal/sync-tsrefs.ts fix",
"check:tsrefs": "node --env-file=.env .internal/sync-tsrefs.ts check"
"check:tsrefs": "node --env-file=.env .internal/sync-tsrefs.ts check",
"versions": "node --env-file=.env .internal/check-versions.ts",
"versions:sync": "node --env-file=.env .internal/check-versions.ts sync"
},
"devDependencies": {
"@biomejs/biome": "^1",
Expand Down
Loading