Skip to content

Gate the workspace manifest dependency graph against cycles - #14524

Merged
baozhoutao merged 4 commits into
mainfrom
claude/issue-14195-workspace-cycle-gate
Sep 2, 2026
Merged

Gate the workspace manifest dependency graph against cycles#14524
baozhoutao merged 4 commits into
mainfrom
claude/issue-14195-workspace-cycle-gate

Conversation

@claude

@claude claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #14195

What

Adds check:workspace-manifest-cycles — a new gate family that walks every
workspace: edge in dependencies, devDependencies, peerDependencies and
optionalDependencies across all 78 workspace packages and refuses on any
cycle, naming each edge with its declaration class
(A --devDependencies--> B). Before this, nothing in the repo read the
manifest graph for cycles — the only signal was pnpm install's
WARN There are cyclic workspace dependencies: … on stderr, exit 0, which had
been printing for the entire life of the #13513 defect before anyone read it.

peerDependencies is walked deliberately, not as an afterthought: a scan
omitting it reports 0 cycles on the pre-fix #13513 tree, honestly and
wrongly — that cycle's first edge was a peerDependencies edge (#13513
comment 5473800149, resolved in 5479206575). The --self-test battery
drives that exact adversarial shape (a cycle whose ONLY closing edge is
peerDependencies, and the same for optionalDependencies) so a future edit
that narrows the walked classes back to three reddens the self-test.

Wired into lint.yml immediately after check:turbo-task-graph, the repo's
existing collector idiom for this kind of manifest sweep.

File surface (exactly three files, per the claim)

  • NEW scripts/check-workspace-manifest-cycles.mjs — the gate. Resolves edges
    by package NAME (never directory) via the shared
    scripts/workspace-enumerator.mjs. A workspace: spec naming a package no
    member declares is recorded as an informational note, never a refusal (a
    separate defect this gate does not judge). A self-edge counts as a cycle of
    length 1. Cycle detection is Tarjan SCCs + a deterministic DFS walk per
    cyclic component, so every disjoint cycle is reported, not just the first
    one found. Exit 0 green, 1 cycle found, 2 usage/read error.
  • package.json — one new scripts key,
    "check:workspace-manifest-cycles": "node scripts/check-workspace-manifest-cycles.mjs --self-test && node scripts/check-workspace-manifest-cycles.mjs",
    the check:turbo-task-graph idiom.
  • .github/workflows/lint.yml — one new step in the same job, immediately
    after check:turbo-task-graph, with a header comment stating what it
    judges, why, and that --self-test runs first.

No other file touched: no existing scripts/check-*.mjs edited, nothing
under scripts/pm/** (held by #14233 in flight), no workspace package.json,
turbo.json or pnpm-workspace.yaml edits.

Proof on the real tree

Green on origin/main before this change (nothing reads the graph for
cycles):

$ git grep -i cycl origin/main -- scripts/check-*.mjs   # prose only, no gate

Green with the new gate:

$ node scripts/check-workspace-manifest-cycles.mjs
OK: 78 workspace package(s), 470 `workspace:` edge(s) (dependencies=343,
devDependencies=121, peerDependencies=5, optionalDependencies=1) — the
manifest graph has no cycle.

Red-proof (temporarily added packages/spec's peerDependencies a
workspace:* edge back onto apps/docs, which already has a
devDependencies edge onto packages/spec — the two form a 2-cycle whose
only closing edge is a peer, the #13513 shape):

$ node scripts/check-workspace-manifest-cycles.mjs
FAIL: the workspace manifest graph has 1 cycle over `workspace:` edges in
dependencies, devDependencies, peerDependencies and optionalDependencies.

  - @objectstack/docs --devDependencies--> @objectstack/spec --peerDependencies--> @objectstack/docs

Reverted with git checkout HEAD -- packages/spec/package.json, confirmed
clean with git diff HEAD (0 lines) before committing.

Self-test: node scripts/check-workspace-manifest-cycles.mjs --self-test
acyclic green; a 2-cycle via devDependencies red with both edges named;
the peer-only and optional-only false-zero controls both red; a 3-node cycle
red with all three edges in cycle order; a self-edge as a length-1 cycle; two
disjoint cycles both reported; a non-workspace: spec is not an edge; all
four workspace:* / ^ / ~ / <range> spellings count; an unresolved
workspace: spec is recorded, not a refusal; both read-error refusals fire.
All cases pass, on the base commit and again on the merged tree.

Gates

Derivation: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/check-workspace-manifest-cycles.mjs package.json .github/workflows/lint.yml
→ 37 commands. All ran green except three that need a full pnpm build first
(check-test-completeness.mjs, check:dual-build-cjs-loads,
check:type-check-debt — all exit 3, "Run pnpm build first ... nothing was
measured", unrelated to this diff's surface). check:pm-dispatch-gates ran
detached (~11 min): dispatch-gates self-test: 1240 cases pass.
check:declared-population-live --list shows the new family LIVE:
live check:workspace-manifest-cycles -- pnpm-workspace.yaml/**, packages/**/package.json, apps/**/package.json, examples/**/package.json.
node scripts/pm/bare-root-worklist.mjs --self-testOK self-test: 57 live row(s), 49 unreachable as spelled, 49 recorded verdict(s) — none stale, none missing, none contradicted... — the new gate entered no unjudged row.
node scripts/check-nul-bytes.mjs — clean. Re-ran the full union after
git fetch origin main && git merge origin/main (clean, no conflicts) on
85e8fc3f5 — same result.

Changeset

None — scripts + workflow + a root scripts key only, no package publishes
anything here. skip-changeset label applied on open, confirmed by reading
the PR's labels back.


Generated by Claude Code

Adds check:workspace-manifest-cycles, a new check family that walks every
workspace: edge in dependencies, devDependencies, peerDependencies and
optionalDependencies across all 78 workspace packages and refuses on any
cycle, naming each edge with its declaration class. peerDependencies is
walked deliberately: a scan omitting it reports 0 cycles on the pre-fix
#13513 tree, honestly and wrongly, since that cycle's first edge was a
peer edge. Wired into lint.yml next to check:turbo-task-graph, the
repo's existing collector idiom for this kind of manifest sweep.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
… COMPOUND_ANCHOR_LEDGER

The self-test declaration this PR adds in
scripts/check-workspace-manifest-cycles.mjs (runSelfTest) is a compound
name the anchor matches, and the pm-dispatch-gates census requires every
such name be classified. It is a genuine self-test battery, so
accidental: false, placed in file-path order among the other
scripts/check-*.mjs accidental:false rows.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants