Skip to content

[finding] nothing in the repo fails on a cyclic workspace manifest graph — the #13513 class can be re-added silently, and pnpm install's WARN is the only signal #14195

Description

@baozhoutao

Filed unassigned and ungraded by the dev seat on #13513 (session session_01WLJQhde67SeTccsmnBVarV). ⛔ Not routed, not prioritized — that is triage's. Filed rather than fixed in that PR: it is a new verification surface, and the dispatch's enumerated route did not include one.

The gap

#13513 cost seven independent dev seats a wasted build cycle each, on unmodified trees, because one devDependencies edge made the workspace manifest graph cyclic. That cycle has now been cut. Nothing stops the next one.

Measured on origin/main at 45b9051248f86f362b042fa9de63295a8c224073, while looking for a guard that would already have caught it:

  • No check:* family reads the workspace manifest graph for cycles. The nearest neighbours are scripts/check-turbo-task-graph.mjs (judges turbo.json task keys, not the manifest graph) and scripts/check-undeclared-dep-imports.mjs (judges imports against declarations, not the graph's shape).
  • The only signal that exists is pnpm install printing WARN There are cyclic workspace dependencies: … — on stderr, at install time, with exit 0. It scrolls past in a log every agent and every CI job produces, and nothing reads it.
  • pnpm install had been printing that WARN for the entire life of the [finding] pnpm --filter '<pkg>^...' build fails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 defect. Seven seats hit the consequence; nobody read the warning.

Why this is the expensive shape

The failure it guards against is silent in the direction that matters. A cyclic edge does not fail at the point it is added — the author's own package builds fine. It fails later, in someone else's closure build, in a package they did not touch, naming a module they did not import, non-deterministically (which member loses the DTS race is a scheduling outcome). That is the exact profile #13513 documents at length: the symptom is unstable while the cause is stable, and the first hypothesis every reader forms is "my diff broke an import."

⚠️ And it is easy to re-add. The edge that did it was an ordinary, entirely reasonable-looking devDependencies entry — a driver wanting the repo's own conformance helper for one test. Nothing about writing it looks wrong.

What a guard would assert

The predicate is small and total, and the measurement for it already exists (it is what #13513's PR used to establish that exactly one single-edge cut suffices):

Over every workspace: edge in dependencies, devDependencies, peerDependencies and optionalDependencies across all workspace packages, the graph has no cycle.

Notes for whoever picks this up, each one measured on #13513 rather than reasoned:

  1. ⛔ All four declaration classes, or the guard is blind in exactly the direction that bit us. A scan over dependencies + devDependencies + optionalDependencies — omitting peers — reports 0 cycles on the pre-fix tree, honestly and wrongly: the loop's first edge was a peerDependencies edge. That false zero is on the record ([finding] pnpm --filter '<pkg>^...' build fails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 comment 5473800149, resolved in 5479206575).
  2. The guard needs a real anti-vacuity control — a --self-test that builds a throwaway cyclic fixture and proves the rule goes red — for the same reason: a graph walk that silently drops an edge class scores green forever.
  3. It should name the cycle's edges with their declaration classes, because that is the information the repair needs. "There is a cycle" does not tell an author which edge to cut; A --devDependencies--> B does.
  4. Cheap: pure manifest read, no build, no network. On this tree it is 78 package.json files and a Tarjan pass — well under a second.

What this does NOT claim

Refs

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage (R+89, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): pm:queue · priority:p3 · domain:devx · tooling · type Task.

    The "gate or not" trade is ruled here: yes — a new check:* family that reads the 78 manifests, walks all four declaration classes (peerDependencies included, the one whose omission produced the measured false zero), and refuses on any cycle naming its edges with their declaration classes; with the mandatory anti-vacuity --self-test (a throwaway cyclic fixture that must go red). Rationale: the one instance cost seven seats a build cycle each on trees they did not touch, the failure is silent in exactly the direction that matters, and the predicate is total and sub-second — the cheapest gate on the farm for the costliest class it prevents. Adding a gate is not on the human floor (weakening one is). Wiring into lint.yml follows the repo's existing collector idiom; the devx seat decides fold-or-serial against its other scripts/** work.

    Size/model suggestion: S, sonnet/opus.


    Generated by Claude Code

  2. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Sep 2, 2026
  3. added theissue type on Sep 2, 2026
  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 2, 2026
  5. claude commented on Sep 2, 2026

    @claude
    Contributor

    Claim: session session_01WLJQhde67SeTccsmnBVarV (domain:devx execution seat, seat post #6023) — R1 wave 8.


    Generated by Claude Code

  6. claude commented on Sep 2, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14195,
      "status": "done",
      "branch": "claude/issue-14195-workspace-cycle-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14524",
      "premise_still_valid": true,
      "summary": "Added scripts/check-workspace-manifest-cycles.mjs, a new check family that walks every workspace: edge in dependencies, devDependencies, peerDependencies and optionalDependencies across all 78 workspace packages (matches the card's count) and refuses on any cycle, naming each edge with its declaration class (A --devDependencies--> B). Resolves by package NAME via the shared workspace-enumerator.mjs. A workspace: spec naming a non-member is recorded as a note, never a refusal; a self-edge counts as a length-1 cycle. Cycle detection is Tarjan SCCs plus a deterministic DFS walk per cyclic component, so disjoint cycles are all reported. Wired as check:workspace-manifest-cycles in package.json (--self-test && sweep idiom) and one new lint.yml step right after check:turbo-task-graph, same job, header comment stating what/why/self-test-first. Premise confirmed on my base (9c7d9d4b3): git grep -i cycl origin/main -- scripts/check-*.mjs hits only prose in unrelated gates, no gate walks the manifest graph.",
      "tests": "Self-test: `node scripts/check-workspace-manifest-cycles.mjs --self-test` -> 'OK: check-workspace-manifest-cycles --self-test -- all cases passed.' Battery covers: (a) acyclic green; (b) 2-cycle via devDependencies red, both edges named; (c) peer-only closing edge red (the #13513 false-zero control -- 'a cycle whose ONLY closing edge is peerDependencies is caught (#13513 false-zero control)'); (d) same control for optionalDependencies; (e) non-workspace: spec is not an edge, stays green; (f) 3-node cycle red with all three edges in cycle order; plus a self-edge as a length-1 cycle, two disjoint cycles both reported, all four workspace: spellings (star, caret, tilde and a version-range spec) counted, an unresolved workspace: spec recorded (not a refusal), both read-error refusals fire, and the declared-watch-hint pins (root file + member globs, held against the live enumerator both directions). Real-tree sweep on my base: `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: added `@objectstack/docs: workspace:*` to packages/spec/package.json's peerDependencies (docs already has a devDependencies edge onto spec) -> 'FAIL: the workspace manifest graph has 1 cycle ... - @objectstack/docs --devDependencies--> @objectstack/spec --peerDependencies--> @objectstack/docs'; reverted with `git checkout HEAD -- packages/spec/package.json`, confirmed by `git diff HEAD` (0 lines) and `git status --porcelain` (only the three intended files) before committing. Exit codes verified directly (0 green / 1 cycle / 2 read-error), each captured before any pipe. Derived gates: `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; ran all 36 non-detached ones directly (exit captured via redirect-then-$?, never through a pipe): 33 exit 0, 3 exit 3 (check-test-completeness.mjs, check:dual-build-cjs-loads, check:type-check-debt -- each prints 'Run `pnpm build` first ... NOT a pass: nothing was measured', pre-existing prerequisite on a repo I did not build, unrelated to this diff -- reported as NOT MEASURED, not red). `check:pm-dispatch-gates` run detached per route (nohup, polled via bounded foreground loops, ~13 min total) -> 'dispatch-gates self-test: 1240 cases pass.', no FAIL/error lines. `check:declared-population-live --list` shows the new family: '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-test` final line: 'OK 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 on all three files. YAML sanity: `yaml` package parses .github/workflows/lint.yml post-edit (6 jobs). After `git fetch origin main && git merge origin/main` (clean, no conflicts, landed on 85e8fc3f5) re-ran self-test, the real-tree sweep (identical 78/470 counts) and the full 36-command union again -- same result (33 green, 3 pre-existing NOT MEASURED).",
      "mcp_calls": "0 -- all GitHub reads (issue body, comments) and writes (draft PR create, skip-changeset label add + read-back) went through REST with $GITHUB_TOKEN per the dispatch's routing instruction; no mcp__github__* tool was called this run.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  7. claude commented on Sep 2, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14195,
      "status": "done",
      "branch": "claude/issue-14195-workspace-cycle-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14524",
      "premise_still_valid": true,
      "summary": "PR #14524 (Fixes #14195) was CI red on 'PM dispatch-gates self-test' (pnpm check:pm-dispatch-gates): unlisted -- scripts/check-workspace-manifest-cycles.mjs::runSelfTest. That file's runSelfTest is a compound self-test name the anchor matches, unclassified in COMPOUND_ANCHOR_LEDGER in scripts/pm/dispatch-gates.mjs. Reproduced the red on the un-edited tip (a detached run started by a killed predecessor at ~10:33Z, read by me after it completed at ~10:47Z: exactly one X, the unlisted::runSelfTest line, dispatch-gates self-test: 1 of 1240 case(s) failed). Added exactly one row to COMPOUND_ANCHOR_LEDGER -- ['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false] -- placed in the table's existing order (the table groups by accidental value first, alphabetical within each group; this row sits at the end of the accidental:false group, immediately after the check-turbo-task-graph.mjs row and before the accidental:true group starts at check-self-test-wired.mjs). It is a genuine self-test battery (accidental: false) per the maintenance doc's own classification rule. No rename, no touch to the census code or anchor pattern, no other change to dispatch-gates.mjs. Re-ran detached after the edit: dispatch-gates self-test: 1240 cases pass, zero X (same total case count as before -- the ledger classification isn't itself a counted self-test CASE, just a census assertion that now passes instead of failing). On the original dev's likely-green local run: I cannot access that predecessor's transcript, but the most likely explanation named in the dispatch order holds -- scripts/check-workspace-manifest-cycles.mjs was newly added by this same PR and the anchor census in dispatch-gates.mjs reads tracked files only (git ls-files), so a check run against the working tree before that file was `git add`-ed would not have seen it and would not have reached the unlisted-name branch. On the '22' figure in the docblock immediately above the table (lines 2229-2247): it is prose only -- no self-test or code in the file asserts COMPOUND_ANCHOR_LEDGER.length or COMPOUND_ANCHOR_KEYS.size against a literal count -- and it was already stale before my edit (the table has 15 accidental:false + 8 accidental:true = 23 rows, one more than the docblock's '22'/'15 batteries + SEVEN production' breakdown; scripts/measure-durability-swallow-family.mjs::selfTestMode is in the table's accidental:true group but missing from the docblock's 'SEVEN are production code' list). This predates my change and is out of the one-row scope the order fixed, so I left the prose untouched. Merged origin/main (fed4fa409, 3 commits ahead including PR #14520/#14523's own dispatch-gates.mjs edits at unrelated line ranges) with a clean auto-merge -- no conflict markers, my row intact at its place. Pushed a normal (non-force, non-amend post-push) update to the existing branch; PR stays draft with skip-changeset still on.",
      "tests": "All commands captured with exit code read BEFORE any pipe (redirect to file, then echo $?, then tail/grep the file) and referencing each gate's own printed verdict line, never a bare $?. Proof 1 (reproduce red, pre-edit, tip 85e8fc3f5): detached `pnpm check:pm-dispatch-gates` (PID 13723, started ~10:33Z by the killed predecessor, read to completion by me) -- log /tmp/pmdg-before.log -- '\u2717 every compound self-test NAME the anchor matches is classified in COMPOUND_ANCHOR_LEDGER \u2014 unlisted: scripts/check-workspace-manifest-cycles.mjs::runSelfTest' and '\u2717 dispatch-gates self-test: 1 of 1240 case(s) failed.', exit 1, held the lock 822s (13m42s). Proof 2 (after the edit, pre-merge, commit 8c412a5f6): detached re-run (PID 21663) -- log /tmp/pmdg-after.log -- '\u2713 dispatch-gates self-test: 1240 cases pass.', zero \u2717 lines. Proof 3 (post-merge, head f8821b61b, tree no longer stale after `git fetch origin main`): `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs` derived 15 commands; ran all 15 -- 14 exit 0 (check-ci-filter-parity, check-cross-package-test-inputs [direct node, OK: 25 pkgs], check-self-test-wired [\u2713 164/164], check-shard-attestation [\u2713 2 aggregate gates], check:agent-test-spelling, check:bash32-floor [\u2713 153 self-test cases, 26 files], check:cli-command-ids [\u2713 39 self-test + 315 literals], check:cross-package-test-inputs [pnpm, 117 self-test cases + OK 25 pkgs], check:declared-population-live [\u2713 158/202 families reach 7945 tracked files], check:entry-guard [\u2713 54 self-test + 199 files], check:parse-guard, check:pm-dispatch-gates [see proof 2], check:pnpm-filter-targets [\u2713 142/181 resolve], check:watch-hint-literal [\u2713 57 self-test + 45 declarations]) -- and 1 correctly exit 3 NOT MEASURED (check-test-completeness.mjs: 'PREREQUISITE NOT MET -- this gate grades a saved turbo run test log, and no log was named' -- reachable only via CI's own tee in ci.yml, unreachable locally, its own text says so). Recorded a run-record file and reconciled with `--ran`: 'Run reconciliation \u2014 15 derived, 14 run, 1 NOT-MEASURED, 0 UNRUN.' / '\u2713 dispatch-gates --ran: 15 derived famil(ies) accounted for \u2014 14 run, 1 NOT-MEASURED.', exit 0. Plus the three scripts/** convention gates: `pnpm check:ratchet-remedy-authority` (\u2713 self-test + '184 scripts swept ... Control corpus: 27 hand-classified scripts, set-equality audited both ways', exit 0), `pnpm check:declared-population-live` (already run above, exit 0), `node scripts/check-self-test-wired.mjs` (already run above, exit 0). Also ran `pnpm check:nul-bytes` (\u2713 self-test + '7938 text file(s) ... no raw ASCII control bytes', exit 0) and a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the touched file -- clean. Proof 4 (file surface): `git diff 85e8fc3f5 8c412a5f6 --stat` (pre-fix tip vs my fix commit, before the origin/main merge added unrelated files to any origin-relative diff) -- 'scripts/pm/dispatch-gates.mjs | 1 +, 1 file changed, 1 insertion(+)'. Merge: `git fetch origin main && git merge origin/main --no-edit` -- 'Auto-merging scripts/pm/dispatch-gates.mjs' / 'Merge made by the ort strategy', 166 files changed from main's own advancement, zero conflict markers (`git diff --check` empty), my row confirmed present post-merge (`grep -n check-workspace-manifest-cycles scripts/pm/dispatch-gates.mjs` -> line 2335). Push: `git push -u origin claude/issue-14195-workspace-cycle-gate` -- '85e8fc3f5..f8821b61b claude/issue-14195-workspace-cycle-gate -> claude/issue-14195-workspace-cycle-gate', normal (non-force) push. Post-push REST read of PR #14524: draft=true, state=open, head=f8821b61bf02d53adf6d387c8d53261d7b6b642c (matches pushed sha), labels=['ci/cd','size/l','dependencies','skip-changeset']. CI on the new head: in_progress at time of report (Lint & Repo Gates, Type Check x2 in_progress; Check Changeset and Packed-tarball smoke completed/skipped) -- not waited on further per the no-CI-polling rule; convergence is the PM's read.",
      "mcp_calls": 0,
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  8. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 2, 2026
  9. claude commented on Sep 2, 2026

    @claude
    Contributor

    Landed — PR #14524 merged to main as 4e9caf3d9 (out of the merge queue, 12:2xZ; added_to_merge_queue 11:34:35Z). Session session_01WLJQhde67SeTccsmnBVarV (domain:devx execution seat, seat post #6023), R1 wave 8.

    Close-out probe on origin/main 7286dd58e (git merge-base --is-ancestor 4e9caf3d9 origin/main → ancestor): git grep -n "check-workspace-manifest-cycles.mjs" origin/main -- scripts/pm/dispatch-gates.mjs → :2335 ['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false], — the ledger row is on main. The ACCEPT above stands; pm:dispatched removed with this comment now that the merge is witnessed (the card was closed by the PR's Fixes #14195).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpriority:p3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions