fix(core): reject undefined layer node dependencies - #48818
vidit19sharma wants to merge 2 commits into
Conversation
Dependency arrays are built at module-evaluation time, so a circular import
can leave an entry undefined instead of a node. Nothing validated that, and
the failure only surfaced later while walking the graph, where `resolve`
reads `.name` off the undefined entry:
TypeError: undefined is not an object (evaluating 'a.name')
The stack is minified and names no module, so the origin is invisible. This
is what anomalyco#48372 reported: the server returned a generic "Unexpected server
error" on every prompt, and several people reasonably suspected their
credentials.
Validate dependencies in `make` and `group`, which fails at the module that
built the bad array and names it. `compile` checks too, since `Node` is a
structural interface and a hand-built node never passes through `make`.
The reported case now fails as:
Layer node "@opencode/v2/FileSystem" has an undefined dependency at
index 2. This usually means a circular import: the module that provides
it has not finished initializing. Break the cycle, for example by making
the import type-only.
This is diagnostics only and fixes no cycle on its own; anomalyco#48397 fixes the
filesystem/search cycle that triggered it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxhAy1sXV5Mi7kkrJu4U7W
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
Thanks for splitting this out. I agree with the scope: this improves diagnosis of #48372; #48397 is still needed to remove the filesystem/search runtime cycle. Checking at construction also makes sense for attributing the captured undefined dependency to its owning node. One gap in the structural-node backstop, from reading const badGroup: LayerNode.Node<never> = {
kind: "group",
name: "external-group",
// @ts-expect-error Deliberately malformed dependency
dependencies: [undefined],
}
LayerNode.compile(badGroup)Here This is a source-review finding, not an independent run of your suite or a new platform-validation claim. The normal |
Review of the previous commit surfaced three gaps.
`rewriteReplacementDependencies` walks the graph independently of `walk`,
and hoisting a tagged subtree reaches it directly, so an undefined entry
there still produced the original unattributable error:
TypeError: undefined is not an object (evaluating 'node.name')
This is the production path: location-services always hoists with a
non-empty replacement list, so the rewrite always runs. Validate there too.
`checkDependencies` also called `findIndex` unconditionally, so a node
without a `dependencies` field started throwing an undiagnostic TypeError
where hoisting previously tolerated it. Treat a missing array as nothing to
check, and reject `null` alongside `undefined`.
The comment on the walk check claimed every visitor maps over the
dependencies, which is not true for tagged or unbound nodes and is what hid
the rewrite gap. Reworded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxhAy1sXV5Mi7kkrJu4U7W
|
Good catch — confirmed and fixed in Chasing it turned up a third instance of the same class that neither of us had flagged, and it was the one that mattered.
|
Issue for this PR
Closes #48819.
Follow-up to #48372, split out at @kernel-oops's suggestion on #48397 ("Agreed that dependency validation in
LayerNode.compilewould make diagnosis much clearer. Please do open that as a separate follow-up issue/PR rather than expanding the scope here.").This is diagnostics only. It fixes no cycle on its own — #48397 fixes the
filesystem/filesystem/searchcycle.Important
This should land after or together with #48397, not before it.
On a tree where that cycle is still present, the guard fires during startup, so the compiled binary fails on every command — including
--version— andscript/build.tsfails its own smoke test. I verified both states on macOS arm64: guard alone on currentdevfails the smoke test; guard plus #48397 builds, passes the smoke test, and completes a real prompt. Details below.Type of change
What does this PR do?
Layer dependency arrays are built at module-evaluation time, so a circular import can leave an entry
undefinedinstead of a node. Nothing validated that. The failure surfaced much later, while walking the graph, at:Reading
.nameoff the undefined entry produced:In a release build that stack is minified and names no module, and the server turns it into a generic
Unexpected server error. In #48372 that meant every prompt failed with no indication of the cause — several people reasonably concluded their credentials were at fault.This adds a
checkDependenciesguard:makeandgroup— the primary check. It throws at the module that built the bad array, so the stack points at the responsible module rather than at graph-compilation internals.compile—Nodeis a structural interface, so a hand-built or externally-produced node can reach compilation without passing throughmake.With the guard, the #48372 case fails as:
Naming the node and the index is enough to locate the cycle directly.
The check also runs in
rewriteReplacementDependencies, which walks the graph independently ofwalk. Hoisting a tagged subtree reaches that rewrite directly, andlocation-services.tsalways hoists with a non-empty replacement list, so without a check there the production path still produced the originalnode.nameTypeError.Behaviour change, stated plainly: on a tree that still has an undefined dependency, this fails at startup rather than at the first prompt. That is a real difference in blast radius — an affected build goes from "starts, then fails on every prompt" to "does not start at all", and the build script's smoke test fails. The build is broken in both cases and the early failure is the diagnosable one, but it is why this should not merge ahead of #48397.
Why
make, given the suggestion namedcompileChecking at construction gives a materially better stack — it names the module whose
deps: [...]is wrong, which is the thing you actually need. Bycompiletime that context is gone and only the node name remains.compileis still checked as a backstop for nodes that never went throughmake, so both paths are covered.How did you verify your code works?
macOS 15 (Darwin 25.6.0) arm64, Bun 1.4.2,
devat95daf90.The guard catches the real reported case. With the
filesystem/searchcycle still unpatched, bundling and running the actual application modules:versus the
TypeError: undefined is not an object (evaluating 'a.name')without it.No false positives. With the same unpatched cycle but the safe import order, the guard stays silent and the graph resolves normally:
Test suite and checks:
bun testinpackages/core— 1105 pass, 0 fail, 144 filesbun testinpackages/opencode— 3584 pass, 22 skip, 0 fail, 254 filesllm298,tui193,codemode263 — 0 fail.schema,enterpriseandsdk-nexthave failures that are byte-identical with and without this change (verified by stashing it), so they are pre-existing and unrelatedlocationServicesgraph (36 top-level nodes) hoists with replacements and compiles cleanly, so the guard does not false-positive on the shipped graphscript/build.ts --singlewith fix(core): break filesystem cycle in compiled prompts #48397 applied — smoke test passes, and the built binary completes a real promptbun typecheck(repo root, all 30 packages) — 30 successful, 30 totalbunx oxlinton both changed files — 0 errors, and 14 warnings, unchanged from the pre-change baseline on those filesbunx prettier --checkon both changed files — cleanAdded tests in
test/effect/layer-node/layer-node.test.ts, covering: rejection bymake; a correct index for a non-zero position; rejection ingroup; rejection incompilefor a node that bypassedmake; hand-built groups at the root and nested (groups are expanded byflattenbefore a node is visited, so they need their own coverage); hoisting; and the replacement rewrite. Each of these fails against the pre-change source.Not covered by my testing: Windows and Linux, and other binary targets. The change is platform-independent, but I have only exercised it on macOS arm64.
Screenshots / recordings
Not applicable.
Checklist