fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112) (#2135)

* fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112)

A parse worker delivers its accumulated result to the main thread via
postMessage, which structured-clones the payload synchronously on the
worker thread and throws a DataCloneError on the first value it can't
serialize. The reporter's case was a node `properties` value pointing at
a native `toString`. The worker re-posted the throw as {type:'error'},
the pool counted it as a worker death, and under
GITNEXUS_WORKER_POOL_SIZE=1 the same graph re-threw on every respawn
until the slot's budget was exhausted and the whole parse phase aborted
-- defeating even the conservative single-worker workaround.

Add a clone-safety net at the worker result boundary. On a clone failure
the worker isolates the offending file, strips the non-cloneable value
from a plain extraction record (keeping the record -- strictly-missing
data, never wrong) or drops a whole ParsedFile so scope-resolution
re-derives it on the main thread with intact edge data, records the
affected paths on the result, warns naming the field + file so the leak
is diagnosable, and re-posts. Healthy runs are byte-identical: the net
runs only after a real DataCloneError, so there is zero overhead on the
fast path. Skipped paths surface via the parsing processor alongside the
skipped-language telemetry. The strip drops the same values the store
path's JSON.stringify already silently removes, so store/no-store runs
converge.

Scope: PR-1 -- failure mode C, the deterministic POOL_SIZE=1 killer. The
timeout/native-abort graceful-degradation cascade (failure modes A & B)
is coupled to downstream-exclusion + a hard worker watchdog and is
tracked as follow-up work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): fail-closed clone-safety recovery + bound recursion depth (#2135 review)

The clone-safety recovery path could re-arm the #2112 worker-death cascade it
was built to prevent: in postResultCloneSafe the sanitizer call and the re-post
sat outside the try/catch, and containsNonCloneable/stripNonCloneable recursed
with a cycle guard but no depth bound. A throw inside the sanitizer (a RangeError
from a deeply-nested record, reproduced at depth >=3000) escaped to the message
handler's {type:'error'}, which under GITNEXUS_WORKER_POOL_SIZE=1 is the
respawn-budget-exhaustion abort.

Wrap the sanitizer + re-post in their own try/catch so any throw fails closed to
a primitive-only {type:'error'} deliberately, and thread a MAX_CLONE_DEPTH bound
through both scan/strip functions so an over-deep subtree is treated as
non-cloneable (dropped/undefined) instead of overflowing the stack. The
isStructuredCloneable catch-all is left broad on purpose — it bounds
structuredClone's own internal recursion in the non-plain-object probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): harden clone-safety against throwing getters and detached buffers (#2135 review)

Two sanitizer-defeat vectors let the re-post throw a DataCloneError again:

- A throwing getter on a record: containsNonCloneable/stripNonCloneable read
  obj[key], so a getter that throws escaped the scan/strip pass. Read defensively
  — a throwing property read is treated as non-cloneable (scan returns true,
  strip drops the property).
- A detached ArrayBuffer/TypedArray: both passed buffers/views through
  unconditionally, but structuredClone rejects a detached one, so the re-post
  threw. Route buffers/views through the authoritative isStructuredCloneable
  probe instead. No byteLength heuristic — a legitimately empty new Uint8Array(0)
  also has byteLength 0 yet clones fine, so a length check would false-positive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): memoize stripped copies so DAG-aliased records aren't over-dropped (#2135 review)

stripNonCloneable carried a shared `seen` WeakSet and returned the ORIGINAL
(un-stripped) value on revisit. When a non-cloneable was reachable via two paths
(a DAG), the second path spliced the original function-bearing object back into
the output, so the rebuilt element failed the last-resort isStructuredCloneable
guard and the whole record was dropped as "unsalvageable" — contradicting the
"record kept, value stripped" contract.

Replace the WeakSet with a Map<object, stripped-copy>: allocate the empty copy,
memoize it before recursing into children (so cycles return the in-progress
copy), and return the memoized copy on revisit. DAG-aliased subtrees now collapse
to one shared stripped copy and are kept-and-stripped, not dropped. The array
branch moves from .map() to allocate-then-push so its identity can be
pre-inserted. Object Map/Set keys aren't identity-preserved across stripping —
acceptable because parse-result Maps are primitive-keyed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(parse): single-pass clone-safety scan preserving array identity (#2135 review)

makeWorkerResultCloneSafe scanned each dirty array twice — a field-level
whole-array containsNonCloneable probe, then a per-element pass — and always
reassigned the field. Fold into one per-element pass that builds the output
array lazily (copying the clean prefix only once the first dirty element
appears) and reassigns the field only when something changed. A fully-clean
array is now scanned once and keeps its referential identity; the clean prefix
of a dirty array is copied by reference. Behavior is otherwise identical
(failure-path-only code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): drop unused generic + pin clone-safe field names to keyof (#2135 review)

makeWorkerResultCloneSafe carried a generic `<T extends Record<string,unknown>>`
that was never load-bearing (it mutates in place and returns {skipped}), and the
call site passed untyped string-literal option sets — so renaming `parsedFiles`
or `skippedPaths` would silently disable the drop-whole / skip protection.

Drop the generic (plain `Record<string,unknown>` param) and type the option sets
at the call site as `Set<keyof ParseWorkerResult>`, so a field rename is now a
compile error. The `as unknown as Record<string,unknown>` widening stays — it's
the standard cast for a no-index-signature interface (TS rejects a single-step
`as`); the function genuinely operates structurally on the result's arrays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): keep the per-file reason in the clone-safety skip log (#2135 review)

The processor's skipped-file warning logged only the paths, dropping the
per-file reason the worker already attached — losing the distinction between a
recoverable "stripped N value(s)" and a whole-record "dropped" entry. Format each
entry as `path (reason)` so the aggregate line carries the diagnostic detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): deterministic findFilePath attribution for ParsedNode (#2135 review)

findFilePath swept all child objects one level deep in Object.keys order, so a
ParsedNode could be attributed to a sibling child's path-like key instead of its
real path at properties.filePath. Check the known `properties` child first, then
fall back to the generic sweep, so node attribution is deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): zero skippedPaths in the slim cache result (#2135 review)

slimParseWorkerResultsForCache spread the worker result without clearing the
clone-safety skippedPaths telemetry, so a sanitized result persisted its skip
list into the on-disk parse-cache shard. Replay already ignores the field; zero
it (like calls/assignments/parsedFiles) to keep shards lean and the intent
explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): exercise real postResultCloneSafe wiring + tighten RED control (#2135 review)

The integration GREEN worker re-implemented postResultCloneSafe inline, so the
production wiring (the {type:'warning'} post + the skippedPaths append) had no
coverage, and the RED control asserted a bare .rejects.toThrow() that any
failure would satisfy.

Extract postResultCloneSafe into a side-effect-free module (post-result.ts) —
importing it from the parse-worker entry module would construct the parser, post
ready, and attach the real handler — and have the GREEN test worker import and
call the real one. Tighten the RED matcher to the actual abort contract
(/circuit breaker|consecutive failures|respawn budget|could not be cloned/),
which also documents that the raw poison result aborts via the pool's
consecutive-failure circuit breaker under POOL_SIZE=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): recover the clone-safety net from any post failure, not only DataCloneError (#2135 review)

The V8 structured-clone research surfaced the net's one real correctness hole:
structuredClone invokes getters, and a getter that THROWS surfaces its own error
(a RangeError, etc.) — NOT a DataCloneError (confirmed against a real
MessageChannel). postResultCloneSafe gated recovery on isDataCloneError, so such
a throw re-threw past the sanitizer and re-armed, under POOL_SIZE=1, the
worker-death cascade the net exists to prevent.

Attempt the sanitize + re-post recovery for ANY first-post failure (the sanitizer
already reads properties defensively, so a throwing getter is dropped), falling
closed to a primitive-only {type:'error'} only if the re-post still fails. Adds
an integration case: a node with a throwing getter is recovered and delivered,
not re-thrown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): name the exact offending key path in the clone-skip diagnostic (#2135 review)

The clone-safety net's skip reason named only the array field + file ("stripped
1 value from nodes"), not the offending property key — which is precisely why
the original #2112 leak stayed unpinned. Thread a dotted key path through
stripNonCloneable (recording each stripped value's path: properties.toString,
meta.data[3], …) and surface the first few in the reason ("from nodes:
properties.toString"). Now a single log line — or the contract/strict checks —
names the leaking property, so a residual runtime escape can be fixed at source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): clone contract — a representative ParseWorkerResult is structured-cloneable (#2135 review)

Shape-regression guard: builds a representative ParseWorkerResult (typed as the
real interface) and asserts isStructuredCloneable. Typing it as ParseWorkerResult
makes adding a new boundary field a compile error here until the test is updated,
and the runtime assert catches a field whose type regresses to a non-cloneable
shape — independent of language input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): strict-mode clone gate (GITNEXUS_STRICT_CLONE) — fail loudly instead of silent sanitize (#2135 review)

The runtime net's silent recovery in production is exactly what let the original
#2112 leak stay unpinned. Add an opt-in strict mode (GITNEXUS_STRICT_CLONE=1,
inherited by workers): on a clone failure, postResultCloneSafe THROWS with the
exact offending key path instead of sanitizing + delivering, so a leak
introduced by a future provider/extractor change fails loudly at its origin
(CI/dev) rather than being quietly stripped. Off in production, where the net
keeps the run alive.

Adds a self-contained integration case (sets the flag, asserts the poison run
rejects with the key path) and skips the synthetic-poison suite under a global
strict run (its value there is running the REAL-extractor integration tests
under strict). Wiring a strict CI lane (GITNEXUS_STRICT_CLONE=1 on a vitest
integration step) is left to the maintainer — it needs a green full-suite
verification and touches the protected workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): don't ship pipelineResult across the analyze-worker IPC boundary (#2112)

The forked analyze worker reports completion to the parent over
child_process IPC, which uses Node's DEFAULT 'json' serialization
(api.ts forks with no `serialization:` option). `AnalyzeResult.pipelineResult`
is populated on every successful analysis and carries `pipelineResult.graph`
— the live KnowledgeGraph closure object. Sending the raw result is wrong
three ways: (1) the graph's nodes/relationships getters force-materialize
the entire graph into two arrays and JSON-stringify them on every analyze,
discarded immediately (a multi-hundred-MB no-op on a large repo — the #2112
scenario); (2) the graph's methods are own function properties that JSON
drops silently, so a surviving graph is a husk whose forEachNode() throws far
from the cause; (3) a BigInt/circular value anywhere in the payload makes
process.send throw TypeError synchronously — caught and re-sent as
{type:'error'}, mis-reporting a SUCCESSFUL analysis (DB already written) as a
FAILURE. This is the #2112 failure family on the server path, and unlike the
parse-worker result boundary it has no clone-safety net.

The parent (api.ts) reads only result.repoName; pipelineResult's real
consumers (CLI skill generation, cli/analyze.ts) call runFullAnalysis
in-process and never cross this fork. So project the result down to an
explicit JSON-safe allowlist of scalar fields. Typed as
Omit<AnalyzeResult,'pipelineResult'> so a future non-serializable field added
to AnalyzeResult fails to compile until handled here deliberately.

Found by the #2112 cross-process serialization-boundary audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): Cloneable<T> + assertCloneable() compile-time clone-boundary guard (#2143)

The runtime clone-safety net is the production backstop; this is its
compile-time complement. The worker result is plain data except a few
`unknown`-typed sinks (a node's `properties` bag, the provider
`extractTemplateConstraints` / `collectCaptureSideChannel` hook returns) —
`unknown` lets a non-serializable value (a function, a leaked tree-sitter
SyntaxNode, …) cross the structured-clone boundary with no compile-time
guard. That is the structural hole #2112 leaked through.

`Cloneable<T>` is a homomorphic recursive mapped type that maps a function or
symbol member to `never`, so a struct carrying one is no longer assignable to
its own `Cloneable<T>`. `assertCloneable(value)` is a runtime identity (zero
cost) whose parameter is `T extends Cloneable<T> ? T : Cloneable<T>`, so a
clone-unsafe argument fails to compile, naming the offending key.

Because it is a homomorphic mapped type it preserves `interface` shapes and
`readonly` modifiers and needs NO index signature on the payload types — this
sidesteps the "closed interface is not assignable to a recursive
index-signature type" wall that blocked the original value-typed-`Cloneable`
attempt (the reason #2143 was deferred from PR #2135). The conditional
parameter type avoids the `T extends Cloneable<T>` circular-constraint error.

Tests: runtime identity contract, plus type-level @ts-expect-error assertions
(enforced by tsconfig.test.json) that a function/symbol member is rejected and
clean interface payloads are accepted. Applied to the real provider hooks in
the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): guard provider clone-boundary hooks with assertCloneable (#2143)

Apply the compile-time guard to the provider hooks that feed the `unknown`-typed
worker-result sinks, so a future non-serializable value in their payloads is a
compile error at the source site rather than a runtime DataCloneError at the
worker post:

- C++  extractTemplateConstraints  (CppConstraintPayload)
- C++  collectCaptureSideChannel    (CppCaptureSideChannel)
- C    collectCaptureSideChannel    (CCaptureSideChannel)
- Kotlin collectCaptureSideChannel  (KotlinCaptureSideChannel)

The C++ template-constraint adapter previously returned `unknown`; it now
returns the concrete `CppConstraintPayload | undefined` and routes its payload
through `assertCloneable`. The side-channel hooks are wrapped at their provider
wiring sites. `assertCloneable` is a runtime identity, so behavior is unchanged
(C static-linkage + C++ constraint suites stay green); the guarantee is the
type-check — src tsc now proves every nested member of those real payload trees
is structured-clone safe.

Test: type-level assertions (enforced by tsconfig.test.json) that each concrete
payload type is `Cloneable<T>`, INDEPENDENT of the provider wiring — so the
regression is caught even if the assertCloneable wrapper is later removed.
Proven non-vacuous (a function-bearing type fails the same assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): scan an array's non-index own properties in the clone sanitizer (#2135 review)

structuredClone serializes an array's NON-index own-enumerable properties (e.g.
`arr.meta = fn`) and throws DataCloneError on a non-cloneable one. The clone
sanitizer's array branches iterated numeric indices only, so such an array was
waved through (containsNonCloneable returned false, makeWorkerResultCloneSafe
left the field unrewritten with skipped:[]) — the re-post then threw, fell
through to the fail-closed {type:'error'}, and re-armed the POOL_SIZE=1 cascade
the net exists to prevent.

Add isArrayIndexKey() and, in BOTH containsNonCloneable and stripNonCloneable
array branches (kept in lockstep), scan/strip the non-index own-enumerable keys
after the index loop. A cloneable non-index prop is carried onto the stripped
copy; a non-cloneable one is stripped and recorded. Not reachable from current
parse output (no extractor attaches non-index array props) — a defense-in-depth
hole closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): contain a throw inside the clone sanitizer instead of escaping to fail-closed (#2135 review)

findFilePath was documented "never throws" but read element properties
unguarded in its generic sweep — a throwing getter at a non-path key (or a
Proxy with a throwing ownKeys trap) threw out of makeWorkerResultCloneSafe, past
postResultCloneSafe's recovery, to the fail-closed {type:'error'} that under
POOL_SIZE=1 re-arms the cascade the net prevents. Likewise a Proxy with a
throwing getPrototypeOf trap throws inside containsNonCloneable's instanceof
checks.

- findFilePath/pathFromChild now read via safeGet (try/catch) and guard
  Object.keys, honoring the "never throws" contract.
- Each element's sanitize in makeWorkerResultCloneSafe is wrapped: a throw during
  scan/strip drops that one element (recorded as "sanitizer error") rather than
  sinking the whole result — so one pathological element can't fail-close the run.
- Corrected the makeWorkerResultCloneSafe JSDoc ("ONLY after a DataCloneError" →
  after ANY post failure, matching the caller) and documented the deliberate
  failure-path double-traversal (the non-allocating pre-scan is what preserves
  clean-element referential identity).

Tests: a throwing getter on a path-less element is stripped & delivered (not
escaped); a Proxy structural-trap element is dropped, clean siblings survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): add a final cloneable postcondition gate to the clone sanitizer (#2135 review)

makeWorkerResultCloneSafe rewrote only ARRAY result fields, so a future
non-array sink (a nested object / Map result field) carrying a non-cloneable
value — or an array field whose own non-index property the element loop didn't
reach — would survive the sanitizer and throw on the re-post. Add a final
`if (!isStructuredCloneable(result))` gate that strips any remaining offending
field in place, making "the returned result is structured-cloneable" a hard
postcondition independent of future ParseWorkerResult shape. Failure-path-only
and a no-op once the array loop already made the result clean (the per-field
probe short-circuits every clean field, so it adds no work or skip entries then).

Tests: a function on a non-array result field is stripped & the result becomes
cloneable; the gate adds no skip entry when the array loop already cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): reject an `any`-typed member in the Cloneable<T> compile-time guard (#2135 review)

`Cloneable<any>` previously resolved to `any` (not `never`), so a payload with
an `any`-typed member — the most likely escape hatch, since `unknown` is already
blocked — passed `assertCloneable` with no compile error. Add an `IsAny<T>`
branch (the canonical `0 extends 1 & T` probe) as the FIRST arm so `any` resolves
to `never`, matching how `unknown` is already rejected. It must precede the
primitive arm: `any extends CloneablePrimitive` would otherwise resolve to `any`
and re-admit it.

The IsAny-first arm perturbs inference for a bare `undefined` literal argument
(T infers as `unknown` → never); real consumers pass `X | undefined` unions
(the provider hooks), which are unaffected (src tsc clean), so the runtime
identity test now uses a `string | undefined` value — the realistic shape.

Tests: an `any` member fails `assertCloneable` (@ts-expect-error, enforced by
tsconfig.test.json) and `Cloneable<any>` resolves to `never` at the type level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): type the analyze-worker IPC projection as a Pick allowlist, not Omit (#2135 review)

`AnalyzeResultIpc = Omit<AnalyzeResult,'pipelineResult'>` kept every other field
in the type — including optional ones like `isPrimaryBranch?` — so the type
advertised a field the runtime allowlist never sends, and the doc-comment's
"a future field fails to compile until handled here" only held for REQUIRED
fields. Switch to `Pick<AnalyzeResult, …the six scalar fields…>`: the allowlist
IS the type, so the projection return literal is exhaustive by construction
(omitting a key is a compile error) and a new `AnalyzeResult` field is simply
absent from the wire until deliberately added here. `isPrimaryBranch` is
intentionally excluded (nothing consumes it server-side over this fork; the
parent reads only `repoName`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): remove the now-dead isDataCloneError export (#2135 review)

postResultCloneSafe recovers on ANY fast-path post failure and never inspects
the error type (a throwing getter surfaces a RangeError, not a DataCloneError —
gating on the type was the original net-gap bug). isDataCloneError has no
production caller; it was only exercised by its own unit test. Remove the
function and that test block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): use the exported SkippedPath type in parsing-processor (#2135 review)

The clone-safety telemetry accumulator inlined `Array<{path,reason}>` — a
structural duplicate of the exported `SkippedPath`. Import and use the canonical
type so a future rename of its fields is a compile error here instead of a silent
structural drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(parse): document the cloneable-return contract on the worker-boundary hooks (#2135 review)

extractTemplateConstraints and collectCaptureSideChannel return `unknown` and
feed values across the worker structured-clone boundary, but the hook contracts
didn't state the cloneability requirement — a future language implementing them
without care could leak a non-serializable value. Document that the return MUST
be structured-clone-safe and should be wrapped with assertCloneable, so the
guarantee is a compile error at the source (#2143).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): assert the clone-skip telemetry surfaces in the GREEN integration case (#2135 review)

The GREEN clone-safety integration test asserted only graph content (all files
present), not that the skippedPaths / {type:'warning'} wiring its docstring
claims to cover actually fired. Capture the production logger via _captureLogger
and assert the sanitize telemetry names the offending file (poison.ts) AND the
exact stripped key path (properties.toString) — proving the worker's
skippedPaths append + the parsing-processor warning surfaced end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): cover the IPC projection against a real KnowledgeGraph (#2135 review)

The IPC projection tests used a hand-built hostile object. Add a case that puts
a real createKnowledgeGraph (whose nodes/relationships getters would materialize
the whole graph under JSON.stringify) in pipelineResult and asserts the
projection drops it entirely — the serialized payload stays under 300 bytes
(a materialized 50-node graph would be thousands), with the scalar fields intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): cover the unsalvageable-drop branch and the skippedPaths merge union (#2135 review)

Two untested clone-safety branches from the tri-review:

- "dropped unsalvageable": a dirty element whose stripped copy is STILL not
  structured-cloneable must be dropped, not delivered (else the re-post throws).
  Add a deterministic test (a non-plain member with a stateful getter that the
  strip-time probe sees clean but that turns into a function on the post-strip
  verification) asserting the element is dropped and the run survives.

- mergeResult skippedPaths union across sub-batches. mergeResult (and its
  appendAll helper) was module-private in the parse-worker ENTRY module, which a
  main-thread test can't import (it runs MessagePort setup). Extract it to a
  side-effect-free result-merge.ts (mirroring post-result.ts) and unit-test the
  union (including the `??=` target-init path), the skippedLanguages sum, and
  array append. parse-worker imports it back; verified the built worker still
  parses + merges via the real-worker integration path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(parse): root-prettier format the clone-safety review-fix files (#2135 review)

Clears the failing `quality / format` CI gate (root prettier, not the
gitnexus-local config). Reformats the pre-existing #2143 wrapping lines in
c-cpp.ts + kotlin.ts plus the clone-safety review-fix files touched in this
PR-update (clone-safety.ts and the new/updated tests). Formatting-only — no
behavior change; tsc, the type-level assertions (tsconfig.test.json), and the
unit + integration suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): avoid js/trivial-conditional in the type-level clone assertions (#2135 review)

CodeQL flagged the `expect(a && b && c).toBe(true)` lines in the type-level test
assertions as js/trivial-conditional: after type erasure the operands are
constant `true`, so the `&&` chain always evaluates the same. Replace the `&&`
chain with array equality (`expect([...]).toEqual([true, ...])`) — no
conditional, and the real assertions remain the `const x: …IsNever = true` /
`: IsCloneable<…> = true` annotations (enforced by tsconfig.test.json, which
fail to compile if a guard regresses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-10 13:47:22 +01:00 • committed by GitHub
parent b1c2a84352
commit 63a7e2b1e0
18 changed files with 2091 additions and 60 deletions

View file

@ -187,6 +187,12 @@ interface LanguageProviderConfig {
* `undefined` when no constraints exist / the node isn't a templated
* function. Languages without SFINAE / concept semantics leave this
* undefined and the disambiguation is a pass-through.
*
* Cloneability contract: the returned payload crosses the worker boundary
* via structured clone, so it MUST be structured-clone-safe (no functions,
* symbols, or tree-sitter `SyntaxNode`s — only plain data). Wrap the return
* with `assertCloneable` from `workers/clone-safety.ts` so a future leak is a
* compile error at the source instead of a runtime DataCloneError (#2143).
*/
readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown;
@ -343,8 +349,12 @@ interface LanguageProviderConfig {
* disk store WITHOUT a main-thread re-parse. The main thread restores them
* via the matching `ScopeResolver.applyCaptureSideChannel` hook.
*
* MUST return plain data (objects / arrays / primitives) so it round-trips
* through `JSON.stringify` + the parsedfile-store interning reviver.
* Cloneability contract: MUST return plain data (objects / arrays /
* primitives — no functions, symbols, or tree-sitter `SyntaxNode`s) so it
* survives BOTH the worker→main structured clone AND `JSON.stringify` + the
* parsedfile-store interning reviver. Wrap the return with `assertCloneable`
* from `workers/clone-safety.ts` so a future non-serializable leak is a
* compile error at the source instead of a runtime DataCloneError (#2143).
*
* Default: undefined (provider has no capture-time module-level side effects).
*/

View file

@ -65,7 +65,11 @@ import {
cppReceiverBinding,
collectCppCaptureSideChannel,
} from './cpp/index.js';
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
import {
extractCppTemplateConstraints,
type CppConstraintPayload,
} from './cpp/constraint-extractor.js';
import { assertCloneable } from '../workers/clone-safety.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -405,7 +409,11 @@ export const cProvider = defineLanguage({
// `static` functions look non-file-local on the main thread and leak into
// cross-file global free-call resolution / wildcard imports. See
// `c/capture-side-channel.ts`.
collectCaptureSideChannel: collectCStaticLinkageSideChannel,
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather
// than a DataCloneError at the worker boundary (#2143).
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectCStaticLinkageSideChannel(filePath)),
interpretImport: interpretCImport,
interpretTypeBinding: interpretCTypeBinding,
bindingScopeFor: cBindingScopeFor,
@ -480,7 +488,7 @@ export const cppProvider = defineLanguage({
// just populated for this file into plain data on `ParsedFile.captureSideChannel`,
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
// re-parse (#1983). See `cpp/capture-side-channel.ts`.
collectCaptureSideChannel: collectCppCaptureSideChannel,
collectCaptureSideChannel: (filePath) => assertCloneable(collectCppCaptureSideChannel(filePath)),
interpretImport: interpretCppImport,
interpretTypeBinding: interpretCppTypeBinding,
bindingScopeFor: cppBindingScopeFor,
@ -501,7 +509,9 @@ export const cppProvider = defineLanguage({
* functions whose constraints the extractor can't model — both cases
* result in no constraint suffix on the node ID.
*/
function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown {
function extractCppTemplateConstraintsForProvider(
definitionNode: SyntaxNode,
): CppConstraintPayload | undefined {
// Walk up to the enclosing template_declaration. Bound the walk so we
// can't accidentally land on a far-ancestor template_declaration that
// wraps an unrelated function.
@ -530,5 +540,8 @@ function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): u
}
break;
}
return extractCppTemplateConstraints(templateDecl, declarator);
// Guard the boundary at the source: a future non-cloneable member of the
// constraint payload becomes a compile error here, not a runtime
// DataCloneError at the worker post (#2143).
return assertCloneable(extractCppTemplateConstraints(templateDecl, declarator));
}

View file

@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { kotlinClassConfig } from '../class-extractors/configs/jvm.js';
import { defineLanguage } from '../language-provider.js';
import { assertCloneable } from '../workers/clone-safety.js';
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
import { kotlinExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@ -182,7 +183,11 @@ export const kotlinProvider = defineLanguage({
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
// re-parse (#1983). Without this, companion/static dispatch emits no CALLS
// edges on the worker path. See `kotlin/capture-side-channel.ts`.
collectCaptureSideChannel: collectKotlinCaptureSideChannel,
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather
// than a DataCloneError at the worker boundary (#2143).
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectKotlinCaptureSideChannel(filePath)),
interpretImport: interpretKotlinImport,
interpretTypeBinding: interpretKotlinTypeBinding,
bindingScopeFor: kotlinBindingScopeFor,

View file

@ -7,6 +7,7 @@ import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './c
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
import type { SkippedPath } from './workers/clone-safety.js';
import { logger } from '../logger.js';
import type {
ParseWorkerResult,
@ -196,6 +197,29 @@ export const dispatchChunkParse = async (
logger.warn(` Skipped unsupported languages: ${summary}`);
}
// Clone-safety telemetry (#2112): files whose parse output carried a value
// the structured-clone algorithm couldn't serialize across the worker
// boundary. The worker sanitized/dropped the offending value so the run
// could complete; surface the (rare) data loss so it's visible and the
// offending extractor can be fixed at source.
const skippedPaths: SkippedPath[] = [];
for (const result of chunkResults) {
for (const entry of result.skippedPaths ?? []) skippedPaths.push(entry);
}
if (skippedPaths.length > 0) {
// Keep the per-file reason ("stripped N value(s) from nodes" /
// "dropped non-serializable parsedFiles entry") — it distinguishes a
// recoverable strip from a whole-record drop, which a path-only line loses.
const shown = skippedPaths
.slice(0, 10)
.map((e) => `${e.path} (${e.reason})`)
.join(', ');
const more = skippedPaths.length > 10 ? ` …and ${skippedPaths.length - 10} more` : '';
logger.warn(
` Sanitized ${skippedPaths.length} file(s) with non-serializable parse output: ${shown}${more}`,
);
}
onFileProgress?.(total, total, 'done');
return chunkResults;
};

View file

@ -0,0 +1,545 @@
/**
* Structured-clone safety for the worker result boundary (#2112).
*
* A parse worker delivers its accumulated result to the main thread via
* `parentPort.postMessage(...)`. Node serializes that payload with the
* structured-clone algorithm SYNCHRONOUSLY on the worker thread, and it
* THROWS a `DataCloneError` the instant it meets a value it can't serialize —
* a function, a symbol, a Promise, a WeakMap, etc. The reporter of #2112 hit
* exactly this: a node record whose `properties` carried an own-enumerable
* value pointing at a native function (`function toString() { [native code] }
* could not be cloned`). One such value aborted the entire parse phase,
* because the worker re-posts the throw as `{type:'error'}` which the pool
* counts as a worker death — and under `GITNEXUS_WORKER_POOL_SIZE=1` the same
* graph re-throws on every respawn until the slot's budget is exhausted.
*
* This module is the safety net. It runs ONLY after a real clone failure on
* the fast-path post (zero overhead on healthy runs), and rewrites the
* boundary-crossing arrays so the result becomes cloneable: a non-cloneable
* value inside a plain extraction record is dropped (the record is otherwise
* kept — strictly-missing data, never wrong), and a `ParsedFile` that can't be
* made cloneable is dropped whole so scope-resolution re-derives it on the
* main thread (where there is no clone boundary) with intact edge data.
*
* Language-neutral by construction: it keys on value shape and field name
* only, never on a language (AGENTS.md shared-pipeline rule). The strip
* semantics mirror what the store path's `JSON.stringify` already silently
* drops, so store / no-store / cold / warm runs converge on the same graph.
*/
/** A file whose parse result was sanitized or dropped at the clone boundary. */
export interface SkippedPath {
/** Best-effort source path of the offending record (or `(unknown)`). */
path: string;
/** Human-readable reason, e.g. "dropped 1 non-serializable value from nodes". */
reason: string;
}
/**
* True iff `value` survives Node's structured-clone algorithm (the same
* algorithm `postMessage` uses). This is the authoritative probe — it matches
* the real failure exactly, including Map/Set/Date/RegExp/TypedArray support,
* so it never false-positives on the `Scope` Maps that clone fine.
*/
export function isStructuredCloneable(value: unknown): boolean {
try {
structuredClone(value);
return true;
} catch {
return false;
}
}
// ── Compile-time boundary guard (#2143) ─────────────────────────────────────
// The runtime net above is the production backstop; this is its compile-time
// complement. The worker result is plain data EXCEPT a few `unknown`-typed
// sinks (a node's `properties` bag, the provider `extractTemplateConstraints` /
// `collectCaptureSideChannel` hook returns). `unknown` lets a non-serializable
// value (a function, a leaked tree-sitter `SyntaxNode`, …) pass with no
// compile-time guard — that is the structural hole #2112 leaked through. Typing
// those producers as `Cloneable<T>` turns such a leak into a compile error at
// the source site instead of a runtime DataCloneError far downstream.
/** The leaf values the structured-clone algorithm copies verbatim. */
type CloneablePrimitive = undefined | null | boolean | number | bigint | string;
/**
* Maps `T` to itself when every value reachable from it is structured-clone
* safe, and to a type containing `never` at the first offending property
* otherwise. A function or symbol — the values `postMessage` rejects — becomes
* `never`, so a struct carrying one is no longer assignable to its own
* `Cloneable<T>` and `assertCloneable` rejects it, naming the bad key.
*
* Implemented as a homomorphic mapped type (`{ [K in keyof T]: … }`) so it
* preserves `interface` shapes and `readonly` modifiers and works WITHOUT
* requiring the payload types to carry an index signature — sidestepping the
* "closed interface is not assignable to a recursive index-signature type" wall
* that blocked the value-typed-`Cloneable` approach (#2143). `Map`/`Set`/array
* containers recurse into their element types; `Date`/`RegExp` are clone-safe
* leaves.
*/
/** True iff `T` is `any` (the canonical `IsAny` probe: only `any` satisfies `0 extends 1 & T`). */
type IsAny<T> = 0 extends 1 & T ? true : false;
export type Cloneable<T> =
IsAny<T> extends true
? never // an `any`-typed member defeats the guard — reject it like `unknown` (both → never)
: T extends CloneablePrimitive | Date | RegExp
? T
: T extends (...args: never[]) => unknown
? never
: T extends symbol
? never
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<Cloneable<K>, Cloneable<V>>
: T extends ReadonlySet<infer U>
? ReadonlySet<Cloneable<U>>
: T extends readonly (infer U)[]
? T extends unknown[]
? Cloneable<U>[]
: readonly Cloneable<U>[]
: T extends object
? { [K in keyof T]: Cloneable<T[K]> }
: never;
/**
* Identity at runtime (zero cost — returns its argument unchanged); a
* compile-time assertion that `value` is structured-clone safe. Wrap a
* producer that feeds an `unknown` worker-result sink:
*
* collectCaptureSideChannel: (filePath) => assertCloneable(collectFoo(filePath))
*
* If `collectFoo`'s return type ever gains a non-cloneable member (a function, a
* `SyntaxNode`, …) the call fails to compile, pointing at the offending key.
*
* The parameter is a conditional type rather than an `extends Cloneable<T>`
* constraint because a self-referential constraint (`T extends Cloneable<T>`)
* is a "circular constraint" error in TypeScript. For a clone-safe `T` the
* parameter resolves to `T` (call type-checks as a plain identity); for an
* unsafe `T` it resolves to `Cloneable<T>` (which has `never` at the bad key),
* so the argument is rejected.
*/
export function assertCloneable<T>(value: T extends Cloneable<T> ? T : Cloneable<T>): T {
return value as T;
}
/**
* Recursion cap for the module's own traversal. An over-deep subtree is treated
* as non-cloneable rather than recursing to a stack overflow — without this, a
* deeply-nested record would throw `RangeError` inside the sanitizer and (since
* the recovery path is the safety net) re-arm the very cascade #2112 fixes. Set
* far below the observed ~3000-frame overflow and far above any real
* parse-result record (extraction records are shallow plain data). Note: this
* caps the module's recursion only; `structuredClone`'s own internal recursion
* (the `isStructuredCloneable` probe of non-plain objects) is bounded by that
* helper's catch-all, which turns a probe-side `RangeError` into a
* non-cloneable verdict — so do not narrow that catch.
*/
const MAX_CLONE_DEPTH = 200;
/**
* True iff `key` is a canonical array-index string (`"0"`, `"1"`, … `< 2^32-1`)
* — i.e. one of the slots the numeric index loop already visits. Everything
* else returned by `Object.keys(array)` is a NON-index own-enumerable property
* (`arr.meta = …`), which the structured-clone algorithm ALSO serializes (and
* throws on if non-cloneable). The array branches of `containsNonCloneable` and
* `stripNonCloneable` use this to scan those extra keys in lockstep.
*/
function isArrayIndexKey(key: string): boolean {
const n = Number(key);
return Number.isInteger(n) && n >= 0 && n < 4294967295 && String(n) === key;
}
/**
* Non-allocating scan: returns true on the FIRST value structured-clone would
* reject. Used to decide whether an array (or element) needs rewriting at all,
* so clean arrays keep their referential identity and pay no copy cost.
*/
function containsNonCloneable(value: unknown, seen: WeakSet<object>, depth = 0): boolean {
const t = typeof value;
if (t === 'function' || t === 'symbol') return true;
if (value === null || t !== 'object') return false;
// Depth bound: treat an over-deep subtree as non-cloneable (the element is
// then stripped/dropped) instead of overflowing the stack.
if (depth >= MAX_CLONE_DEPTH) return true;
const obj = value as object;
// Cycles clone fine; don't recurse into one twice.
if (seen.has(obj)) return false;
// Structured-clone-native containers carry no non-cloneable payload of their
// own; their *contents* still need scanning (a Map value could be a fn).
if (obj instanceof Date || obj instanceof RegExp) return false;
// Buffers/views usually clone, but a DETACHED one is rejected by
// structuredClone — probe rather than wave it through. No byteLength
// heuristic: a legitimately empty `new Uint8Array(0)` also has byteLength 0
// yet clones fine, so a length check would false-positive.
if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) return !isStructuredCloneable(obj);
seen.add(obj);
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (containsNonCloneable(obj[i], seen, depth + 1)) return true;
}
// structuredClone also serializes an array's NON-index own-enumerable
// properties and throws on a non-cloneable one — scan them too (lockstep
// with stripNonCloneable's array branch; see isArrayIndexKey).
for (const key of Object.keys(obj)) {
if (isArrayIndexKey(key)) continue;
let child: unknown;
try {
child = (obj as unknown as Record<string, unknown>)[key];
} catch {
return true; // a throwing getter can't be serialized either
}
if (containsNonCloneable(child, seen, depth + 1)) return true;
}
return false;
}
if (obj instanceof Map) {
for (const [k, v] of obj) {
if (containsNonCloneable(k, seen, depth + 1) || containsNonCloneable(v, seen, depth + 1))
return true;
}
return false;
}
if (obj instanceof Set) {
for (const v of obj) {
if (containsNonCloneable(v, seen, depth + 1)) return true;
}
return false;
}
// A non-plain object (Promise, WeakMap, class instance with internal slots)
// that structured clone can't handle: detect via the authoritative probe.
// Plain objects fall through to a property scan (cheap, no allocation).
const proto = Object.getPrototypeOf(obj);
if (proto !== Object.prototype && proto !== null) {
if (!isStructuredCloneable(obj)) return true;
return false;
}
for (const key of Object.keys(obj)) {
let child: unknown;
try {
child = (obj as Record<string, unknown>)[key];
} catch {
// A getter that throws can't be serialized either — treat as non-cloneable.
return true;
}
if (containsNonCloneable(child, seen, depth + 1)) return true;
}
return false;
}
/**
* State carried through a strip pass. `stripped` counts dropped values for the
* skip report; `seen` memoizes each visited object to its stripped COPY (not a
* bare visited-set) so a DAG-aliased subtree — the same object reached via two
* paths — is sanitized once and shared, never over-dropped, and cycles
* terminate by returning the in-progress copy.
*/
interface StripCtx {
stripped: number;
seen: Map<object, unknown>;
/**
* Dotted key paths (relative to the element root) of every value that was
* stripped/dropped — e.g. `properties.toString`, `meta.data[3]`. Surfaced in
* the skip reason so the offending property is named precisely, which is what
* lets a still-unpinned leak be located from a single log line (#2112).
*/
keys: string[];
}
/** Record a strip at `path` (root → `(root)`); keeps the count + key path in sync. */
function recordStrip(ctx: StripCtx, path: string): void {
ctx.stripped++;
ctx.keys.push(path === '' ? '(root)' : path);
}
/**
* Deep-copy `value`, replacing any value structured-clone would reject with
* `undefined` (which clones fine). Preserves primitives, arrays, plain
* objects, and the structured-clone-native containers (Date, RegExp, Map,
* Set, ArrayBuffer, TypedArray). Rebuilds only what it must — clean leaves are
* returned by reference. `path` is the dotted key path of `value` (for the
* diagnostic record).
*/
function stripNonCloneable(value: unknown, ctx: StripCtx, depth = 0, path = ''): unknown {
const t = typeof value;
if (t === 'function' || t === 'symbol') {
recordStrip(ctx, path);
return undefined;
}
if (value === null || t !== 'object') return value;
// Depth bound (mirrors containsNonCloneable): drop an over-deep subtree to
// `undefined` (itself cloneable, and a legal property value / array element)
// rather than overflowing the stack.
if (depth >= MAX_CLONE_DEPTH) {
recordStrip(ctx, path);
return undefined;
}
const obj = value as object;
// Memoized? Return the SAME stripped copy (preserves DAG shape; terminates
// cycles by returning the in-progress copy inserted before recursing below).
if (ctx.seen.has(obj)) return ctx.seen.get(obj);
// Leaf-like values: returned by reference, but still memoize the decision so
// a second alias resolves identically.
if (obj instanceof Date || obj instanceof RegExp) {
ctx.seen.set(obj, value);
return value;
}
if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
// Keep a live buffer/view (even an empty one); drop a detached one, which
// structuredClone rejects. The probe is exact — no byteLength heuristic.
if (!isStructuredCloneable(obj)) {
recordStrip(ctx, path);
ctx.seen.set(obj, undefined);
return undefined;
}
ctx.seen.set(obj, value);
return value;
}
// Containers: allocate the empty copy, memoize it BEFORE recursing, then fill
// — so a cycle/alias that re-enters gets this in-progress copy.
if (Array.isArray(obj)) {
const out: unknown[] = [];
ctx.seen.set(obj, out);
for (let i = 0; i < obj.length; i++)
out.push(stripNonCloneable(obj[i], ctx, depth + 1, `${path}[${i}]`));
// Carry NON-index own-enumerable props through the same strip (lockstep
// with containsNonCloneable): structuredClone serializes them, so a
// non-cloneable one must be stripped rather than left to throw on re-post.
for (const key of Object.keys(obj)) {
if (isArrayIndexKey(key)) continue;
const childPath = `${path}.${key}`;
let child: unknown;
try {
child = (obj as unknown as Record<string, unknown>)[key];
} catch {
recordStrip(ctx, childPath);
continue;
}
(out as unknown as Record<string, unknown>)[key] = stripNonCloneable(
child,
ctx,
depth + 1,
childPath,
);
}
return out;
}
if (obj instanceof Map) {
// Scope limit (acceptable): object keys aren't identity-preserved across
// stripping. Parse-result Maps are primitive-keyed, so this never bites.
const out = new Map();
ctx.seen.set(obj, out);
for (const [k, v] of obj)
out.set(
stripNonCloneable(k, ctx, depth + 1, `${path}<key>`),
stripNonCloneable(v, ctx, depth + 1, `${path}<map>`),
);
return out;
}
if (obj instanceof Set) {
const out = new Set();
ctx.seen.set(obj, out);
for (const v of obj) out.add(stripNonCloneable(v, ctx, depth + 1, `${path}<set>`));
return out;
}
const proto = Object.getPrototypeOf(obj);
if (proto !== Object.prototype && proto !== null) {
// Non-plain object that the probe already flagged as non-cloneable and
// that we can't safely reconstruct (Promise, WeakMap, class instance with
// internal slots). Drop it whole — memoize the decision so aliases agree.
if (!isStructuredCloneable(obj)) {
recordStrip(ctx, path);
ctx.seen.set(obj, undefined);
return undefined;
}
ctx.seen.set(obj, value);
return value;
}
const out: Record<string, unknown> = {};
ctx.seen.set(obj, out);
for (const key of Object.keys(obj)) {
const childPath = path === '' ? key : `${path}.${key}`;
let child: unknown;
try {
child = (obj as Record<string, unknown>)[key];
} catch {
// A getter that throws is non-serializable — drop the property.
recordStrip(ctx, childPath);
continue;
}
out[key] = stripNonCloneable(child, ctx, depth + 1, childPath);
}
return out;
}
/** Keys checked (top-level and one level deep) to attribute a record to a file. */
const DEFAULT_PATH_KEYS = ['filePath', 'path', 'file'] as const;
/** Read `obj[key]`, returning undefined if the access throws (throwing getter / Proxy trap). */
function safeGet(obj: Record<string, unknown>, key: string): unknown {
try {
return obj[key];
} catch {
return undefined;
}
}
/** Read a path key off a child object (one level deep); never throws. */
function pathFromChild(child: unknown, pathKeys: readonly string[]): string | undefined {
if (child === null || typeof child !== 'object') return undefined;
const crec = child as Record<string, unknown>;
for (const pk of pathKeys) {
const v = safeGet(crec, pk);
if (typeof v === 'string') return v;
}
return undefined;
}
/**
* Best-effort source-path extraction for reporting; never throws. Reads are
* defensive (a throwing getter / Proxy trap on a path-attribution key must not
* escape and abandon the sanitize — it would re-arm the fail-closed cascade).
*/
function findFilePath(element: unknown, pathKeys: readonly string[]): string | undefined {
if (element === null || typeof element !== 'object') return undefined;
const rec = element as Record<string, unknown>;
// Top level first — a ParsedFile carries `filePath` here.
for (const key of pathKeys) {
const v = safeGet(rec, key);
if (typeof v === 'string') return v;
}
// Known child next — a ParsedNode carries its path at `properties.filePath`.
// Prefer it over the generic sweep so attribution is deterministic when a
// sibling child also happens to carry a path-like key.
const fromProps = pathFromChild(safeGet(rec, 'properties'), pathKeys);
if (fromProps !== undefined) return fromProps;
// Generic one-level sweep as the fallback for other shapes.
let keys: string[];
try {
keys = Object.keys(rec);
} catch {
return undefined; // a Proxy ownKeys trap that throws — give up on attribution
}
for (const key of keys) {
if (key === 'properties') continue; // already checked above
const fromChild = pathFromChild(safeGet(rec, key), pathKeys);
if (fromChild !== undefined) return fromChild;
}
return undefined;
}
export interface MakeCloneSafeOptions {
/**
* Array field names whose offending elements are DROPPED whole rather than
* stripped in place (e.g. `parsedFiles` — its `captureSideChannel` drives
* edge resolution, so a stripped-and-delivered file would ship WRONG edges;
* dropping it lets scope-resolution re-derive it on the main thread).
*/
dropWholeElement: ReadonlySet<string>;
/** Field names to skip entirely (e.g. the `skippedPaths` field itself). */
skipFields?: ReadonlySet<string>;
/** Keys to probe for a file path when attributing a skip. */
pathKeys?: readonly string[];
}
/**
* Make a worker result's boundary-crossing array fields structured-cloneable,
* mutating `result` in place. Only arrays that actually contain a
* non-cloneable value are rewritten; everything else keeps referential
* identity. Returns the list of affected file paths for reporting.
*
* Call this after ANY failure of the fast-path post — a `DataCloneError`, OR a
* throwing getter's own error surfaced by structuredClone (the caller in
* `post-result.ts` recovers on any throw, not only `DataCloneError`).
*/
export function makeWorkerResultCloneSafe(
result: Record<string, unknown>,
options: MakeCloneSafeOptions,
): { skipped: SkippedPath[] } {
const pathKeys = options.pathKeys ?? DEFAULT_PATH_KEYS;
const skipped: SkippedPath[] = [];
for (const field of Object.keys(result)) {
if (options.skipFields?.has(field)) continue;
const value = result[field];
if (!Array.isArray(value)) continue;
const dropWhole = options.dropWholeElement.has(field);
// `out` is built lazily — only once a dirty element appears — by copying the
// clean prefix, so a fully-clean array is never rebuilt and keeps its
// referential identity (no field reassignment). A dirty element is scanned
// (containsNonCloneable) and then stripped (stripNonCloneable): two passes,
// deliberately. The non-allocating pre-scan is exactly what lets CLEAN
// elements stay by reference (zero-copy) — replacing it with an
// always-allocating strip would regress that. This whole path is
// failure-path-only (the fast post already threw), so the second pass over
// the rare dirty element is acceptable.
let out: unknown[] | null = null;
for (let i = 0; i < value.length; i++) {
const element = value[i];
try {
if (!containsNonCloneable(element, new WeakSet())) {
if (out) out.push(element);
continue;
}
if (!out) out = value.slice(0, i); // first dirty element: copy clean prefix
const path = findFilePath(element, pathKeys) ?? '(unknown)';
if (dropWhole) {
skipped.push({ path, reason: `dropped non-serializable ${field} entry` });
continue;
}
const ctx: StripCtx = { stripped: 0, seen: new Map(), keys: [] };
const cleaned = stripNonCloneable(element, ctx);
// Last-resort guard: if stripping functions/symbols still left something
// structured-clone rejects, drop the element rather than re-throw.
if (isStructuredCloneable(cleaned)) {
out.push(cleaned);
// Name the offending key path(s) so the leak is locatable from the log
// (e.g. "from nodes: properties.toString") — not just the array field.
const at = ctx.keys.slice(0, 3).join(', ');
const more = ctx.keys.length > 3 ? `, …+${ctx.keys.length - 3}` : '';
skipped.push({
path,
reason: `stripped ${ctx.stripped} non-serializable value(s) from ${field}: ${at}${more}`,
});
} else {
skipped.push({ path, reason: `dropped unsalvageable ${field} entry` });
}
} catch {
// A throw DURING this element's scan/strip — a Proxy with a throwing
// `getPrototypeOf`/`ownKeys` trap reached by Object.getPrototypeOf /
// Object.keys, or any other structural-enumeration throw. Drop the
// element rather than let the throw escape to postResultCloneSafe's
// fail-closed {type:'error'} (which under POOL_SIZE=1 re-arms the
// cascade this net prevents). One pathological element can't sink the
// whole result.
if (!out) out = value.slice(0, i);
skipped.push({ path: '(unknown)', reason: `dropped ${field} entry (sanitizer error)` });
}
}
if (out) result[field] = out;
}
// Final safety gate. The loop above only rewrites ARRAY fields, so a future
// non-array result sink (a nested object / Map) — or an array field whose own
// non-index property the element loop didn't reach — could still hold a
// non-cloneable value and throw on the re-post. Make "the returned result is
// structured-cloneable" a hard postcondition: strip any remaining offending
// field in place. Failure-path-only and a no-op once the result is already
// clean (the per-field probe short-circuits every clean field).
if (!isStructuredCloneable(result)) {
for (const field of Object.keys(result)) {
if (options.skipFields?.has(field)) continue;
if (isStructuredCloneable(result[field])) continue;
const ctx: StripCtx = { stripped: 0, seen: new Map(), keys: [] };
result[field] = stripNonCloneable(result[field], ctx);
const at = ctx.keys.slice(0, 3).join(', ');
skipped.push({
path: '(result)',
reason: `stripped ${ctx.stripped} non-serializable value(s) from ${field}${at ? `: ${at}` : ''}`,
});
}
}
return { skipped };
}

View file

@ -25,6 +25,9 @@ import {
deriveDefaultExportHocName,
} from '../ts-js-hoc-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import type { SkippedPath } from './clone-safety.js';
import { postResultCloneSafe } from './post-result.js';
import { mergeResult } from './result-merge.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type {
ExtractedRouterInclude,
@ -389,6 +392,15 @@ export interface ParseWorkerResult {
*/
parsedFiles: ParsedFile[];
skippedLanguages: Record<string, number>;
/**
* Files whose parse output carried a value the structured-clone algorithm
* couldn't serialize across the worker boundary (#2112). The clone-safety
* net stripped or dropped the offending value so the result could be
* delivered; these paths are surfaced to the operator so the (rare) data
* loss is visible. Optional for cache backward compatibility — older cache
* entries predate the field; consumers must guard with `?? []`.
*/
skippedPaths?: SkippedPath[];
fileCount: number;
}
@ -2333,40 +2345,8 @@ let accumulated: ParseWorkerResult = {
fileCount: 0,
};
let cumulativeProcessed = 0;
// Use a loop instead of push(...spread) to avoid hitting V8's argument limit
// when merging large result sets (push(...arr) calls apply() under the hood
// and blows the stack when arr has >~65k elements).
const appendAll = <T>(target: T[], src: T[]) => {
for (let i = 0; i < src.length; i++) target.push(src[i]);
};
const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
appendAll(target.nodes, src.nodes);
appendAll(target.relationships, src.relationships);
appendAll(target.symbols, src.symbols);
appendAll(target.calls, src.calls);
appendAll(target.assignments, src.assignments);
appendAll(target.routes, src.routes);
appendAll(target.fetchCalls, src.fetchCalls);
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
appendAll(target.decoratorRoutes, src.decoratorRoutes);
if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes);
if (src.routerImports) appendAll(target.routerImports, src.routerImports);
if (src.routerModuleAliases) {
target.routerModuleAliases ??= [];
appendAll(target.routerModuleAliases, src.routerModuleAliases);
}
appendAll(target.toolDefs, src.toolDefs);
appendAll(target.ormQueries, src.ormQueries);
appendAll(target.constructorBindings, src.constructorBindings);
appendAll(target.fileScopeBindings, src.fileScopeBindings);
appendAll(target.parsedFiles, src.parsedFiles);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
}
target.fileCount += src.fileCount;
};
// `mergeResult` (+ its `appendAll`) lives in ./result-merge.ts (extracted so it
// can be unit-tested without importing this entry module).
// Signal the pool that worker-side initialization (parser imports, language
// grammars, type-env setup, all helper modules) is complete and the message
@ -2480,7 +2460,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
accumulated.parsedFiles = [];
}
}
parentPort!.postMessage({ type: 'result', data: accumulated });
postResultCloneSafe(accumulated);
// Reset for potential reuse
accumulated = {
nodes: [],

View file

@ -0,0 +1,91 @@
/**
* Worker → main result delivery with clone-safety (#2112).
*
* Extracted from `parse-worker.ts` into its own side-effect-free module so it
* can be imported and exercised directly (the parse worker is an entry module:
* importing it would construct the parser, post `ready`, and attach the real
* message handler). The integration test imports `postResultCloneSafe` from
* here to cover the production wiring end to end rather than re-implementing it.
*/
import { parentPort } from 'node:worker_threads';
import { makeWorkerResultCloneSafe } from './clone-safety.js';
import type { ParseWorkerResult } from './parse-worker.js';
/**
* Strict mode (opt-in via `GITNEXUS_STRICT_CLONE=1`, inherited by workers). When
* on, a clone failure THROWS with the offending key path instead of silently
* sanitizing + delivering — so a leak introduced by a future provider/extractor
* change fails LOUDLY (in CI / dev) at its origin rather than being quietly
* stripped in production. The silent-recovery behavior is exactly what hid the
* original #2112 leak; strict mode removes the silence where we want loudness.
* Off in production, where the net's job is to keep the run alive.
*/
const STRICT_CLONE = process.env.GITNEXUS_STRICT_CLONE === '1';
/**
* Deliver the accumulated result to the pool, surviving a non-cloneable value
* (#2112). Fast path: post as-is — on a healthy result this is the only thing
* that runs, so clone-safety adds zero overhead to normal runs. If structured
* clone rejects the payload (a function/symbol leaked into an extraction
* record — the reporter's case was a node `properties` value pointing at a
* native `toString`), rewrite the boundary-crossing arrays so the result is
* cloneable, record the affected paths on `result.skippedPaths`, warn the
* operator naming the offending field + file (so the still-unpinned leak is
* diagnosable from logs and fixable at source), and re-post.
*
* Recovery is attempted for ANY first-post failure, not only a `DataCloneError`.
* structuredClone invokes getters, and a getter that THROWS surfaces its own
* error (a `RangeError`, etc.) — NOT a `DataCloneError` (confirmed against a
* real MessageChannel). Gating recovery on `DataCloneError` let such a throw
* re-throw past the sanitizer and re-arm, under `POOL_SIZE=1`, the worker-death
* cascade this net prevents. The recovery path is wrapped in its own try/catch
* so a still-uncloneable re-post fails closed to a primitive-only
* `{type:'error'}` DELIBERATELY rather than escaping the worker.
*/
export function postResultCloneSafe(result: ParseWorkerResult): void {
try {
parentPort!.postMessage({ type: 'result', data: result });
return;
} catch {
// Fall through to recovery on ANY failure (DataCloneError OR a throwing
// getter's own error). A healthy post returned above and never reaches here.
}
try {
// `as unknown as Record<string, unknown>` is the standard widening for a
// no-index-signature interface (TS rejects a single-step `as`). The field
// sets are typed to `keyof ParseWorkerResult` so renaming a field is a
// compile error here, not a silent loss of the drop-whole / skip protection.
const { skipped } = makeWorkerResultCloneSafe(result as unknown as Record<string, unknown>, {
dropWholeElement: new Set<keyof ParseWorkerResult>(['parsedFiles']),
skipFields: new Set<keyof ParseWorkerResult>(['skippedPaths']),
});
if (skipped.length > 0) {
if (STRICT_CLONE) {
// Surface the leak loudly with its exact key path(s) instead of
// delivering a sanitized result. Routes to the catch below → a
// primitive-only {type:'error'} the pool reports, failing CI.
const detail = skipped.map((s) => `${s.path}: ${s.reason}`).join('; ');
throw new Error(
`GITNEXUS_STRICT_CLONE: worker result was not structured-cloneable — ${detail}`,
);
}
result.skippedPaths = [...(result.skippedPaths ?? []), ...skipped];
const sample = skipped
.slice(0, 5)
.map((s) => `${s.path} (${s.reason})`)
.join('; ');
const more = skipped.length > 5 ? ` …and ${skipped.length - 5} more` : '';
if (parentPort) {
parentPort.postMessage({
type: 'warning',
message: `Sanitized ${skipped.length} file(s) with non-serializable parse output before delivery: ${sample}${more}`,
});
}
}
parentPort!.postMessage({ type: 'result', data: result });
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
parentPort!.postMessage({ type: 'error', error: e.message, errorStack: e.stack });
}
}

View file

@ -0,0 +1,56 @@
/**
* Merge of accumulated parse-worker results (sub-batch result → the conceptual
* job's running accumulator).
*
* Extracted from `parse-worker.ts` into this side-effect-free module so the
* merge can be imported and unit-tested directly — the parse worker is an entry
* module (importing it constructs the parser, posts `ready`, and attaches the
* real MessagePort handler), so a main-thread test cannot import a helper out of
* it. Mirrors the `post-result.ts` extraction.
*
* `import type` of `ParseWorkerResult` is erased at runtime, so there is no
* import cycle with `parse-worker.ts` (which imports this module's runtime).
*/
import type { ParseWorkerResult } from './parse-worker.js';
// Use a loop instead of push(...spread) to avoid hitting V8's argument limit
// when merging large result sets (push(...arr) calls apply() under the hood
// and blows the stack when arr has >~65k elements).
const appendAll = <T>(target: T[], src: T[]): void => {
for (let i = 0; i < src.length; i++) target.push(src[i]);
};
/**
* Merge `src` into `target` in place: append every boundary-crossing array,
* sum the per-language skip counts, union the clone-safety `skippedPaths`, and
* add the file count.
*/
export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult): void => {
appendAll(target.nodes, src.nodes);
appendAll(target.relationships, src.relationships);
appendAll(target.symbols, src.symbols);
appendAll(target.calls, src.calls);
appendAll(target.assignments, src.assignments);
appendAll(target.routes, src.routes);
appendAll(target.fetchCalls, src.fetchCalls);
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
appendAll(target.decoratorRoutes, src.decoratorRoutes);
if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes);
if (src.routerImports) appendAll(target.routerImports, src.routerImports);
if (src.routerModuleAliases) {
target.routerModuleAliases ??= [];
appendAll(target.routerModuleAliases, src.routerModuleAliases);
}
appendAll(target.toolDefs, src.toolDefs);
appendAll(target.ormQueries, src.ormQueries);
appendAll(target.constructorBindings, src.constructorBindings);
appendAll(target.fileScopeBindings, src.fileScopeBindings);
appendAll(target.parsedFiles, src.parsedFiles);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
}
if (src.skippedPaths && src.skippedPaths.length > 0) {
(target.skippedPaths ??= []).push(...src.skippedPaths);
}
target.fileCount += src.fileCount;
};

View file

@ -858,6 +858,12 @@ function createJobs<TInput>(
* time spent across all attempts/splits/retries. When the budget is
* exhausted, the pool surfaces the in-flight path via `WorkerPoolDispatchError`
* instead of letting timeouts compound indefinitely.
*
* Upstream of these layers, the parse worker self-sanitizes a result that the
* structured-clone algorithm can't serialize (#2112) — stripping or dropping
* the offending value and reporting the affected paths on the result — so a
* single non-cloneable value can't masquerade as a worker death and exhaust a
* slot's respawn budget here.
*/
export const createWorkerPool = (
workerUrl: URL,
@ -1816,14 +1822,19 @@ export const createWorkerPool = (
if (slotGenerations[workerIndex] !== slotGen) return;
if (settled || stopped) return;
// Native postMessage delivers POJO directly via Node's
// structured clone. V8 deserialization failures (malformed
// frame, non-cloneable value) surface as a `messageerror`
// event handled below — they never reach this handler. The
// only thing we need to guard for here is a worker that
// sends a message without a `type` discriminant (a bug in
// the worker, not a wire-format issue): without the guard
// `null.type` would throw a TypeError out of the
// EventEmitter listener → uncaughtException on the main
// structured clone. Two distinct clone failure modes exist,
// and NEITHER reaches this handler: (1) a SENDER-side
// non-cloneable value (a function/symbol that leaked into the
// result) throws a synchronous `DataCloneError` on the
// worker's own postMessage — the parse worker self-sanitizes
// such results before delivery (#2112) and falls back to a
// primitive-only `{type:'error'}` if it still can't serialize;
// (2) a RECEIVER-side deserialization failure surfaces as a
// `messageerror` event handled below. The only thing THIS
// handler guards is a worker that sends a message without a
// `type` discriminant (a worker bug, not a wire-format issue):
// without the guard `null.type` would throw a TypeError out of
// the EventEmitter listener → uncaughtException on the main
// thread.
const msg = raw as WorkerOutgoingMessage;
if (msg === null || typeof msg !== 'object' || typeof msg.type !== 'string') {
@ -1931,12 +1942,15 @@ export const createWorkerPool = (
}
};
// `messageerror` fires when V8 fails to deserialize a postMessage
// payload (e.g., the worker tries to send a non-cloneable value
// back, or structured-clone hits an unsupported shape). The worker
// stays ALIVE but the message is lost — without this handler the
// pool would sit on the dropped message until the idle timeout
// expires. Treat it as worker death so the resilience layers fire:
// `messageerror` fires when V8 fails to DESERIALIZE a postMessage
// payload on THIS (receiver) side — a value that serialized on the
// worker but can't be reconstructed here. (A non-cloneable value on
// the SENDER side instead throws a synchronous DataCloneError on the
// worker's own postMessage; that path is caught and sanitized
// worker-side (#2112) and never arrives here.) The worker stays ALIVE
// but the message is lost — without this handler the pool would sit on
// the dropped message until the idle timeout expires. Treat it as
// worker death so the resilience layers fire:
// requeue the remainder via `recoverAndResume`, attribute the
// in-flight file from the `starting-file` signal (if observed),
// and let the per-slot respawn budget and circuit breaker decide

View file

@ -0,0 +1,72 @@
/**
* JSON-safe projection of `AnalyzeResult` for the analyze-worker → parent IPC
* boundary (#2112 boundary audit; #2135).
*
* The forked analyze worker (`analyze-worker.ts`) reports completion to the
* parent over `child_process` IPC, which uses Node's DEFAULT `'json'`
* serialization — `api.ts` forks the worker with no `serialization:` option, so
* the channel runs `JSON.stringify`/`JSON.parse`, NOT V8 structured clone.
*
* `AnalyzeResult.pipelineResult` is populated on every successful analysis
* (`run-analyze.ts`) and carries `pipelineResult.graph` — the live
* `KnowledgeGraph` closure object. Sending the raw result across this channel is
* wrong three ways:
* 1. Waste — the graph's `nodes`/`relationships` getters force-materialize the
* ENTIRE graph into two arrays, then JSON-stringify them, on every analyze.
* On a large repo (the #2112 scenario) that is a multi-hundred-MB
* stringify+parse whose result is immediately discarded.
* 2. Silent corruption — the graph's methods are own function properties;
* `JSON.stringify` drops them with no error, so a `pipelineResult.graph`
* that survived the wire is a data-only husk whose `forEachNode(...)` throws
* "is not a function" far from the cause.
* 3. Conditional crash — a BigInt or circular reference anywhere in the
* payload makes `process.send` throw `TypeError` synchronously; the throw is
* caught in the worker and re-sent as `{type:'error'}`, turning a
* SUCCESSFUL analysis (DB already written) into a reported FAILURE. This is
* the #2112 failure family on the server path, and — unlike the parse-worker
* result boundary — it has no clone-safety net.
*
* The parent (`api.ts`) reads only `result.repoName`; `pipelineResult`'s real
* consumers (CLI skill generation) call `runFullAnalysis` in-process and never
* cross this fork. So the worker sends an explicit allowlist of the scalar
* fields, JSON-safe by construction.
*/
import type { AnalyzeResult } from '../core/run-analyze.js';
/**
* The JSON-safe subset of `AnalyzeResult` that crosses the analyze-worker IPC
* boundary. A `Pick` allowlist — NOT `Omit<…, 'pipelineResult'>`. With `Pick`
* the allowlist IS the type, so the projection is exhaustive by construction:
* `projectAnalyzeResultForIpc`'s return literal must name exactly these keys
* (omitting one is a compile error), and a new field added to `AnalyzeResult`
* is simply absent from the wire until it is *deliberately* added here. `Omit`
* couldn't give that guarantee — it kept every other field, including OPTIONAL
* ones (e.g. `isPrimaryBranch?`), so an optional non-serializable field could be
* advertised by the type yet silently dropped by the runtime allowlist.
*
* `isPrimaryBranch` is intentionally excluded: the parent (`api.ts`) reads only
* `repoName`, and nothing consumes `isPrimaryBranch` across this fork (its CLI
* consumer calls `runFullAnalysis` in-process). Add a field here only when a
* server-side IPC consumer actually needs it — and only if it is JSON-safe.
*/
export type AnalyzeResultIpc = Pick<
AnalyzeResult,
'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped'
>;
/**
* Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
* dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
* in the `AnalyzeResultIpc` allowlist. The return literal is exhaustive over
* `AnalyzeResultIpc` (a missing key is a compile error).
*/
export function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResultIpc {
return {
repoName: result.repoName,
repoPath: result.repoPath,
stats: result.stats,
alreadyUpToDate: result.alreadyUpToDate,
ftsRepairedOnly: result.ftsRepairedOnly,
ftsSkipped: result.ftsSkipped,
};
}

View file

@ -11,7 +11,8 @@
* Child -> Parent: { type: 'error', message: string }
*/
import { runFullAnalysis, type AnalyzeOptions, type AnalyzeResult } from '../core/run-analyze.js';
import { runFullAnalysis, type AnalyzeOptions } from '../core/run-analyze.js';
import { projectAnalyzeResultForIpc, type AnalyzeResultIpc } from './analyze-worker-ipc.js';
import { closeLbug } from '../core/lbug/lbug-adapter.js';
interface StartMessage {
@ -29,7 +30,9 @@ interface ProgressMessage {
interface CompleteMessage {
type: 'complete';
result: AnalyzeResult;
// JSON-safe projection (no `pipelineResult` / live KnowledgeGraph). This
// channel is default-JSON child_process IPC — see analyze-worker-ipc.ts.
result: AnalyzeResultIpc;
}
interface ErrorMessage {
@ -79,7 +82,12 @@ process.on('message', async (msg: StartMessage) => {
},
});
send({ type: 'complete', result });
// Send a JSON-safe projection, NOT the raw result: the IPC channel is
// default-JSON serialization and `result.pipelineResult` carries the live
// KnowledgeGraph (wasteful to materialize, silently corrupted by JSON, and
// a BigInt/circular value would throw and mis-report this success as a
// failure). See analyze-worker-ipc.ts.
send({ type: 'complete', result: projectAnalyzeResultForIpc(result) });
} catch (err: any) {
send({ type: 'error', message: err?.message || 'Analysis failed' });
}

View file

@ -202,6 +202,9 @@ export const slimParseWorkerResultsForCache = (
assignments: [],
constructorBindings: [],
parsedFiles: [],
// #2112: a clone-safety skip list is per-run telemetry, not graph data —
// replay ignores it. Drop it so it doesn't bloat the cached shard.
skippedPaths: [],
});
}
return slim;

View file

@ -0,0 +1,291 @@
/**
* #2112 — Integration regression test for the worker result clone-safety net.
*
* Reproduces the deterministic large-repo killer: a parse worker whose
* accumulated result carries a value the structured-clone algorithm can't
* serialize (the reporter's case was a node `properties` value pointing at a
* native `toString`). Before the fix, `parentPort.postMessage({type:'result',
* data})` threw a `DataCloneError` SENDER-side; the worker re-posted it as
* `{type:'error'}`, the pool counted it as a worker death, and under
* `GITNEXUS_WORKER_POOL_SIZE=1` the same graph re-threw on every respawn until
* the slot's budget was exhausted and the whole parse phase aborted.
*
* Runs with REAL `worker_threads` + `createWorkerPool` over the production
* pool / merge / graph wiring, under `workerPoolSize: 1` (matching the
* conservative workaround that still failed in the issue). The GREEN worker is
* an ESM module that statically imports and calls the REAL built
* `postResultCloneSafe` from `dist/` — so this exercises the actual production
* delivery wiring across a real `postMessage` boundary (the fake-worker doubles
* used by the unit suite bypass structured clone entirely and can't reproduce
* the failure).
*
* Build prerequisite: the worker imports `dist/.../post-result.js`, so
* `node scripts/build.js` must run first (the `pretest:integration` step does
* this; a stale `dist/` would test old behavior).
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
import { _captureLogger } from '../../src/core/logger.js';
// file:// URL of the BUILT production result-delivery helper, imported by the
// ESM test worker so it exercises the REAL postResultCloneSafe wiring (the
// {type:'warning'} post + skippedPaths append), not a re-implementation.
const POST_RESULT_URL = new URL('../../dist/core/ingestion/workers/post-result.js', import.meta.url)
.href;
const ACCUMULATED_INIT = `{
nodes: [], relationships: [], symbols: [], calls: [], assignments: [],
routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [],
routerIncludes: [], routerImports: [], toolDefs: [], ormQueries: [],
constructorBindings: [], fileScopeBindings: [], parsedFiles: [],
skippedLanguages: {}, fileCount: 0,
}`;
/**
* Synthesizes a Function node per file. For `poison.ts` it leaks an own native
* `toString` into the node's `properties` — the exact #2112 shape that throws
* `DataCloneError` across the real worker boundary.
*/
const SUB_BATCH_HANDLER = `
if (msg && msg.type === 'sub-batch') {
for (const file of msg.files) {
const baseName = file.path.split('/').pop().replace(/\\.ts$/, '');
const properties = {
name: baseName, filePath: file.path, startLine: 1, endLine: 1,
language: 'typescript', isExported: true,
};
if (file.path.endsWith('poison.ts')) {
properties.toString = Object.prototype.toString; // native fn → non-cloneable
}
accumulated.nodes.push({ id: 'func:' + file.path, label: 'Function', properties });
accumulated.fileCount++;
}
parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}`;
/**
* GREEN worker: delivers via the REAL production postResultCloneSafe, so this
* test covers the actual wiring (the {type:'warning'} post + skippedPaths
* append), not a re-implementation that could drift from production.
*/
const CLONE_SAFE_WORKER = `
import { parentPort } from 'node:worker_threads';
import { postResultCloneSafe } from '${POST_RESULT_URL}';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
${SUB_BATCH_HANDLER}
if (msg && msg.type === 'flush') {
postResultCloneSafe(accumulated);
}
});
`;
/** RED control: posts the non-cloneable result raw (no clone-safety net). */
const RAW_WORKER = `
import { parentPort } from 'node:worker_threads';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
${SUB_BATCH_HANDLER}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: accumulated });
}
});
`;
/**
* GETTER worker: poison.ts's node carries an own-enumerable getter that THROWS.
* structuredClone invokes getters, so this surfaces a RangeError — NOT a
* DataCloneError — at the boundary. The net must still recover (route it into
* the sanitizer), not re-throw past it. Delivers via the real postResultCloneSafe.
*/
const GETTER_WORKER = `
import { parentPort } from 'node:worker_threads';
import { postResultCloneSafe } from '${POST_RESULT_URL}';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
for (const file of msg.files) {
const baseName = file.path.split('/').pop().replace(/\\.ts$/, '');
const properties = {
name: baseName, filePath: file.path, startLine: 1, endLine: 1,
language: 'typescript', isExported: true,
};
if (file.path.endsWith('poison.ts')) {
Object.defineProperty(properties, 'boom', {
enumerable: true,
get() { throw new RangeError('boom getter'); },
});
}
accumulated.nodes.push({ id: 'func:' + file.path, label: 'Function', properties });
accumulated.fileCount++;
}
parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
postResultCloneSafe(accumulated);
}
});
`;
const FIXTURE_FILES = {
'src/good_a.ts': 'export function good_a() { return 1; }\n',
'src/poison.ts': 'export function poison() { return 2; }\n',
'src/good_c.ts': 'export function good_c() { return 3; }\n',
};
const nodeNames = (graph: ReturnType<typeof createKnowledgeGraph>): Set<string> => {
const names = new Set<string>();
for (const n of graph.nodes.values()) {
if (n.label === 'Function') {
const name = (n.properties as { name?: string }).name;
if (name) names.add(name);
}
}
return names;
};
// These cases deliberately inject non-cloneable values, so they're meaningless
// under a global GITNEXUS_STRICT_CLONE=1 run (strict turns the sanitize into a
// throw). Skip the whole suite there — a global strict lane's value is running
// the REAL-extractor integration tests under strict, not this synthetic one.
// The strict-mode case below sets the flag itself (self-contained).
const STRICT = process.env.GITNEXUS_STRICT_CLONE === '1';
describe.skipIf(STRICT)('#2112: worker result clone-safety integration (POOL_SIZE=1)', () => {
let tempDir: string;
let repoDir: string;
const writeWorker = (script: string): URL => {
const p = path.join(tempDir, `clone-skip-worker-${Math.abs(hash(script))}.mjs`);
writeFileSync(p, script);
return pathToFileURL(p) as URL;
};
// Stable name without Math.random (banned in this harness elsewhere) — index by content.
const hash = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h;
};
const runWith = async (workerUrl: URL): Promise<ReturnType<typeof createKnowledgeGraph>> => {
const filePaths = Object.keys(FIXTURE_FILES);
const scanned = filePaths.map((rel) => ({
path: rel,
size: statSync(path.join(repoDir, rel)).size,
}));
const graph = createKnowledgeGraph();
await runChunkedParseAndResolve(
graph,
scanned,
filePaths,
filePaths.length,
repoDir,
1, // deterministic start time (Date.now is banned in this harness)
() => {},
{
skipWorkers: false,
workerUrlForTest: workerUrl,
workerPoolSize: 1, // poison lands on the only slot — the issue's workaround config
},
);
return graph;
};
beforeEach(() => {
tempDir = mkdtempSync(path.join(tmpdir(), 'parse-impl-clone-skip-'));
repoDir = path.join(tempDir, 'repo');
mkdirSync(repoDir, { recursive: true });
for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
const full = path.join(repoDir, rel);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content);
}
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('GREEN: a non-cloneable result is sanitized and delivered; the run completes with all files', async () => {
// Capture the production telemetry (parsing-processor logs the per-file skip
// with its path + reason) so this asserts the REAL skippedPaths/warning
// wiring surfaced, not just that the graph ended up correct.
const cap = _captureLogger();
try {
const graph = await runWith(writeWorker(CLONE_SAFE_WORKER));
const names = nodeNames(graph);
// Survivors AND the sanitized poison file are all present — the run did not abort.
expect(names.has('good_a')).toBe(true);
expect(names.has('good_c')).toBe(true);
// The poison node is delivered with its legitimate data intact (only the
// leaked native `toString` was stripped), so it still lands in the graph.
expect(names.has('poison')).toBe(true);
// The clone-safety telemetry surfaced the offending file AND the exact
// stripped key path — the wiring this suite claims to cover.
const msgs = cap.records().map((r) => String(r.msg ?? ''));
const skipLine = msgs.find(
(m) => m.includes('poison.ts') && m.includes('properties.toString'),
);
expect(
skipLine,
`expected a sanitize warning naming poison.ts + properties.toString; saw: ${msgs.join(' | ')}`,
).toBeDefined();
} finally {
cap.restore();
}
});
it('GREEN: a throwing getter (RangeError, not DataCloneError) is recovered, not re-thrown', async () => {
// structuredClone invokes getters; a throwing getter surfaces its own
// RangeError at the boundary. The net must route it into the sanitizer
// (which drops the offending property) rather than re-throwing past it and
// re-arming the POOL_SIZE=1 worker-death cascade. Without the fix this run
// rejects; with it, all files (incl. the sanitized poison node) are present.
const graph = await runWith(writeWorker(GETTER_WORKER));
const names = nodeNames(graph);
expect(names.has('good_a')).toBe(true);
expect(names.has('good_c')).toBe(true);
expect(names.has('poison')).toBe(true);
});
it('strict mode (GITNEXUS_STRICT_CLONE=1) surfaces the leak loudly with the key path, not silent sanitize', async () => {
// The spawned worker inherits process.env, so postResultCloneSafe runs in
// strict mode: instead of sanitizing + delivering, it THROWS with the exact
// offending key path → the run rejects (a real future extractor leak would
// fail CI loudly at its origin instead of being silently stripped in prod).
const prev = process.env.GITNEXUS_STRICT_CLONE;
process.env.GITNEXUS_STRICT_CLONE = '1';
try {
await expect(runWith(writeWorker(CLONE_SAFE_WORKER))).rejects.toThrow(
/STRICT_CLONE|not structured-cloneable|properties\.toString/i,
);
} finally {
if (prev === undefined) delete process.env.GITNEXUS_STRICT_CLONE;
else process.env.GITNEXUS_STRICT_CLONE = prev;
}
});
it('RED control: without clone-safety, the same poison result aborts the parse phase', async () => {
// Pre-fix behavior: the raw non-cloneable result throws DataCloneError in
// the worker; under POOL_SIZE=1 the pool exhausts the slot's respawn
// budget and rejects. The matcher is the specific contract — not a bare
// .toThrow() — so an unrelated failure (spawn error, stale dist) can't pass
// it and mask a broken RED→GREEN flip.
await expect(runWith(writeWorker(RAW_WORKER))).rejects.toThrow(
/circuit breaker|consecutive failures|respawn budget|could not be cloned/i,
);
});
});

View file

@ -0,0 +1,107 @@
/**
* #2112 boundary audit — the analyze-worker → parent IPC projection.
*
* The forked analyze worker reports completion over default-JSON child_process
* IPC. `AnalyzeResult.pipelineResult` carries the live `KnowledgeGraph` (closure
* methods, getter-materialized arrays) and can transitively hold a BigInt or a
* circular reference — all of which break `JSON.stringify` (the IPC serializer):
* methods drop silently, BigInt/circular THROW. `projectAnalyzeResultForIpc`
* strips `pipelineResult` to an explicit JSON-safe allowlist so the boundary is
* safe by construction. These tests pin that contract.
*/
import { describe, it, expect } from 'vitest';
import { projectAnalyzeResultForIpc } from '../../src/server/analyze-worker-ipc.js';
import type { AnalyzeResult } from '../../src/core/run-analyze.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
/**
* An `AnalyzeResult` whose `pipelineResult` is hostile to JSON in all three
* ways the real `KnowledgeGraph` can be: a method (dropped), a circular ref
* (throws), and a BigInt (throws).
*/
function hostileResult(): AnalyzeResult {
const graph: Record<string, unknown> = {
nodes: [{ id: 'a', label: 'Function', properties: { name: 'a' } }],
relationships: [],
forEachNode: () => {}, // own function property — JSON drops it silently
bigCount: 10n, // BigInt — JSON.stringify throws
};
graph.self = graph; // circular — JSON.stringify throws
return {
repoName: 'demo',
repoPath: '/repos/demo',
stats: { files: 3, nodes: 1, edges: 0 },
alreadyUpToDate: false,
ftsSkipped: true,
pipelineResult: { graph, repoPath: '/repos/demo', totalFileCount: 3 },
};
}
describe('#2112: analyze-worker IPC projection', () => {
it('drops pipelineResult and preserves the scalar fields the parent consumes', () => {
const projected = projectAnalyzeResultForIpc(hostileResult());
expect(projected).toEqual({
repoName: 'demo',
repoPath: '/repos/demo',
stats: { files: 3, nodes: 1, edges: 0 },
alreadyUpToDate: false,
ftsRepairedOnly: undefined,
ftsSkipped: true,
});
expect('pipelineResult' in projected).toBe(false);
});
it('the projection is JSON-serializable (survives the default child_process IPC channel)', () => {
const projected = projectAnalyzeResultForIpc(hostileResult());
// The real failure mode: process.send runs JSON.stringify under the hood.
expect(() => JSON.stringify(projected)).not.toThrow();
const roundTripped = JSON.parse(JSON.stringify(projected));
expect(roundTripped.repoName).toBe('demo');
expect(roundTripped.stats.nodes).toBe(1);
});
it('anchors the hazard: serializing the RAW result throws (the bug the projection prevents)', () => {
// Without the projection, `send({type:'complete', result})` would throw a
// TypeError in the worker, get caught, and mis-report this success as a
// failure. This assertion documents why the projection exists.
expect(() => JSON.stringify(hostileResult())).toThrow(TypeError);
});
it('drops a REAL KnowledgeGraph from pipelineResult — the payload stays tiny (graph not materialized)', () => {
// The synthetic cases above use a hand-built hostile object; this uses the
// real createKnowledgeGraph the server path actually puts in pipelineResult,
// whose nodes/relationships getters would materialize the whole graph into
// arrays under JSON.stringify. The projection must drop it entirely.
const graph = createKnowledgeGraph();
for (let i = 0; i < 50; i++) {
graph.addNode({
id: `n${i}`,
label: 'Function',
properties: { name: `n${i}`, filePath: 'x.ts' },
});
}
graph.addRelationship({ id: 'n0->n1', source: 'n0', target: 'n1', type: 'CALLS' });
const result: AnalyzeResult = {
repoName: 'demo',
repoPath: '/r',
stats: { nodes: 50, edges: 1 },
pipelineResult: {
graph,
repoPath: '/r',
totalFileCount: 50,
resolutionOutcomes: [],
usedWorkerPool: true,
},
};
const projected = projectAnalyzeResultForIpc(result);
expect('pipelineResult' in projected).toBe(false);
const json = JSON.stringify(projected);
// A materialized 50-node graph would be thousands of bytes; the projection
// is just the scalar fields, so this stays small.
expect(json.length).toBeLessThan(300);
const rt = JSON.parse(json);
expect(rt.repoName).toBe('demo');
expect(rt.stats.nodes).toBe(50);
});
});

View file

@ -0,0 +1,86 @@
/**
* #2143 — compile-time boundary guard: `Cloneable<T>` + `assertCloneable()`.
*
* `assertCloneable` is a runtime identity (zero cost); its real value is the
* compile-time guarantee that a producer feeding an `unknown` worker-result
* sink returns only structured-clone-safe data. The `@ts-expect-error` lines
* below ARE the assertions — they make the type-check fail (`tsconfig.test.json`)
* if a non-cloneable payload ever becomes assignable to `Cloneable<T>`; the
* runtime cases pin the identity contract callers rely on.
*/
import { describe, it, expect } from 'vitest';
import { assertCloneable, type Cloneable } from '../../src/core/ingestion/workers/clone-safety.js';
describe('#2143: assertCloneable runtime identity', () => {
it('returns clone-safe values unchanged (zero-cost identity)', () => {
const obj = { kind: 'cpp' as const, names: ['a', 'b'], depth: 3, ok: true };
expect(assertCloneable(obj)).toBe(obj);
const withMap = { m: new Map<string, number>([['a', 1]]) as ReadonlyMap<string, number> };
expect(assertCloneable(withMap)).toBe(withMap);
// `X | undefined` (the real shape provider hooks return — collectFoo(): Foo | undefined).
const maybe: string | undefined = undefined;
expect(assertCloneable(maybe)).toBeUndefined();
const nested = { a: { b: { c: [1, 2, 3] } } };
expect(assertCloneable(nested)).toBe(nested);
});
it('a guarded value really is structured-cloneable (the runtime claim behind the type)', () => {
const payload = { kind: 'cpp' as const, ranges: ['1:2'], inner: { xs: [1, 2] } };
const guarded = assertCloneable(payload);
expect(() => structuredClone(guarded)).not.toThrow();
});
});
describe('#2143: Cloneable<T> compile-time rejection (type-level)', () => {
it('accepts clean interface payloads and rejects function/symbol members', () => {
interface Clean {
readonly kind: 'cpp';
readonly names: readonly string[];
readonly inner: { readonly n: number };
}
const clean: Clean = { kind: 'cpp', names: ['a'], inner: { n: 1 } };
expect(assertCloneable(clean)).toBe(clean); // compiles — clean interface is Cloneable
interface LeakyFn {
readonly name: string;
readonly toString: () => string;
}
const leakyFn: LeakyFn = { name: 'x', toString: () => 'x' };
// @ts-expect-error — a function member is not Cloneable (toString resolves to never)
assertCloneable(leakyFn);
interface LeakySym {
readonly tag: symbol;
}
const leakySym: LeakySym = { tag: Symbol('t') };
// @ts-expect-error — a symbol member is not Cloneable (tag resolves to never)
assertCloneable(leakySym);
// R4 (#2135 tri-review): an `any`-typed member must NOT defeat the guard.
interface LeakyAny {
readonly name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly bag: any;
}
const leakyAny: LeakyAny = { name: 'x', bag: () => {} };
// @ts-expect-error — an `any` member resolves to never, so the payload is rejected
assertCloneable(leakyAny);
// The guard must not be vacuous — these resolve to `never` at the type level.
type FnIsNever = [Cloneable<() => void>] extends [never] ? true : false;
type SymIsNever = [Cloneable<symbol>] extends [never] ? true : false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyIsNever = [Cloneable<any>] extends [never] ? true : false;
const fnIsNever: FnIsNever = true;
const symIsNever: SymIsNever = true;
const anyIsNever: AnyIsNever = true;
// Array equality (not `&&`) so this isn't a trivial-always-true conditional;
// the real assertions are the `: …IsNever = true` annotations above, which
// fail to compile if any guard regresses.
expect([fnIsNever, symIsNever, anyIsNever]).toEqual([true, true, true]);
});
});

View file

@ -0,0 +1,44 @@
/**
* #2143 — the concrete worker-boundary payload types are `Cloneable`.
*
* The provider hooks (`extractTemplateConstraints`, `collectCaptureSideChannel`)
* feed `unknown`-typed worker-result sinks and are wrapped in `assertCloneable`
* at their source sites (c-cpp.ts, kotlin.ts). These type-level assertions pin
* the guarantee INDEPENDENTLY of that wiring: if a future field added to any of
* these payloads is non-serializable (a function, symbol, …), `Cloneable<T>`
* resolves to a type containing `never` at that key and `[T] extends
* [Cloneable<T>]` becomes `false`, so the `: true` annotation fails the
* type-check (`tsconfig.test.json`) — catching the regression even if someone
* later removes the `assertCloneable` wrapper from the provider.
*
* Type-only imports (erased at runtime); the single runtime assertion exists so
* the file is a real, runnable test.
*/
import { describe, it, expect } from 'vitest';
import type { Cloneable } from '../../src/core/ingestion/workers/clone-safety.js';
import type { CppConstraintPayload } from '../../src/core/ingestion/languages/cpp/constraint-extractor.js';
import type { CppCaptureSideChannel } from '../../src/core/ingestion/languages/cpp/capture-side-channel.js';
import type { CCaptureSideChannel } from '../../src/core/ingestion/languages/c/capture-side-channel.js';
import type { KotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
// `[T] extends [Cloneable<T>] ? true : false` is `true` iff T is wholly
// clone-safe. Tuple-wrapped to avoid distributing over unions / `never`.
type IsCloneable<T> = [T] extends [Cloneable<T>] ? true : false;
describe('#2143: worker-boundary payload types are Cloneable', () => {
it('CppConstraintPayload / CppCaptureSideChannel / CCaptureSideChannel / KotlinCaptureSideChannel', () => {
const cppConstraint: IsCloneable<CppConstraintPayload> = true;
const cppSideChannel: IsCloneable<CppCaptureSideChannel> = true;
const cSideChannel: IsCloneable<CCaptureSideChannel> = true;
const kotlinSideChannel: IsCloneable<KotlinCaptureSideChannel> = true;
// Array equality (not `&&`) so this isn't a trivial-always-true conditional;
// the real assertions are the `: IsCloneable<…> = true` annotations above.
expect([cppConstraint, cppSideChannel, cSideChannel, kotlinSideChannel]).toEqual([
true,
true,
true,
true,
]);
});
});

View file

@ -0,0 +1,609 @@
import { describe, expect, it } from 'vitest';
import {
isStructuredCloneable,
makeWorkerResultCloneSafe,
type SkippedPath,
} from '../../src/core/ingestion/workers/clone-safety.js';
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
/**
* #2112: the worker result boundary must survive a value the structured-clone
* algorithm can't serialize. The reporter's case was a node `properties` value
* pointing at a native `toString`, which crashed the whole parse phase.
*/
describe('clone-safety', () => {
describe('isStructuredCloneable', () => {
it('accepts plain data and the structured-clone-native containers', () => {
expect(isStructuredCloneable({ a: 1, b: [2, 3], c: 'x' })).toBe(true);
expect(isStructuredCloneable(new Map([['k', [1]]]))).toBe(true);
expect(isStructuredCloneable(new Set([1, 2]))).toBe(true);
expect(isStructuredCloneable(new Date())).toBe(true);
expect(isStructuredCloneable(/re/g)).toBe(true);
});
it('rejects functions and symbols', () => {
expect(isStructuredCloneable(() => 1)).toBe(false);
expect(isStructuredCloneable({ fn: () => 1 })).toBe(false);
expect(isStructuredCloneable({ s: Symbol('x') })).toBe(false);
});
});
describe('makeWorkerResultCloneSafe', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('leaves a fully cloneable result untouched (referential identity preserved)', () => {
const nodes = [{ id: 'n1', properties: { filePath: 'a.ts', name: 'foo' } }];
const result: Record<string, unknown> = {
nodes,
parsedFiles: [{ filePath: 'a.ts', scopes: [{ bindings: new Map([['x', [1]]]) }] }],
skippedLanguages: { ada: 2 },
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped).toEqual([]);
// Untouched arrays keep their identity (no needless copy).
expect(result.nodes).toBe(nodes);
expect(isStructuredCloneable(result)).toBe(true);
});
it('strips a non-cloneable value from a plain record, keeps the record, attributes the path', () => {
// The exact #2112 shape: a node whose properties carry an own native fn.
const props: Record<string, unknown> = { filePath: 'pkg/bad.cpp', name: 'wedge' };
props.toString = Object.prototype.toString;
const result: Record<string, unknown> = {
nodes: [
{ id: 'good', properties: { filePath: 'pkg/ok.cpp', name: 'ok' } },
{ id: 'bad', properties: props },
],
parsedFiles: [],
skippedLanguages: {},
fileCount: 2,
};
expect(isStructuredCloneable(result)).toBe(false); // red: would crash postMessage
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true); // green: now deliverable
const nodes = result.nodes as Array<{ id: string; properties: Record<string, unknown> }>;
expect(nodes).toHaveLength(2); // record kept, not dropped
expect(nodes[1].properties.toString).toBeUndefined(); // offending value stripped
expect(nodes[1].properties.name).toBe('wedge'); // legitimate data preserved
expect(skipped).toHaveLength(1);
expect(skipped[0].path).toBe('pkg/bad.cpp');
expect(skipped[0].reason).toContain('nodes');
// The reason names the exact offending key path — what lets the leak be
// located from a single log line, not just the array field.
expect(skipped[0].reason).toContain('properties.toString');
});
it('does not touch a result whose only "exotic" value is a clean Map (the refuted Map hypothesis)', () => {
const result: Record<string, unknown> = {
symbols: [{ id: 's', filePath: 'a.ts', bindings: new Map([['t', 'T']]) }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped).toEqual([]);
expect(result.symbols as unknown[]).toHaveLength(1);
});
it('drops a whole ParsedFile when its captureSideChannel is non-cloneable (re-parse path)', () => {
const sideChannel: Record<string, unknown> = { staticNames: ['a'] };
sideChannel.leaked = () => 1; // a function leaked into the side-channel
const result: Record<string, unknown> = {
nodes: [],
parsedFiles: [
{ filePath: 'keep.c', scopes: [] },
{ filePath: 'drop.c', captureSideChannel: sideChannel },
],
skippedLanguages: {},
fileCount: 2,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
const parsedFiles = result.parsedFiles as Array<{ filePath: string }>;
expect(parsedFiles).toHaveLength(1); // bad file dropped whole, not stripped
expect(parsedFiles[0].filePath).toBe('keep.c');
expect(skipped).toHaveLength(1);
expect(skipped[0].path).toBe('drop.c');
expect(skipped[0].reason).toContain('dropped');
});
it('strips a non-cloneable value that is not a function/symbol (e.g. a Promise) and keeps the record', () => {
const result: Record<string, unknown> = {
calls: [
{ id: 'c1', filePath: 'a.ts' },
{ id: 'c2', filePath: 'b.ts', pending: Promise.resolve(1) }, // Promise: not cloneable, not a fn
],
parsedFiles: [],
skippedLanguages: {},
fileCount: 2,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
const calls = result.calls as Array<{ id: string; pending?: unknown }>;
expect(calls.map((c) => c.id)).toEqual(['c1', 'c2']); // record kept
expect(calls[1].pending).toBeUndefined(); // unsalvageable value stripped to undefined
expect(skipped).toHaveLength(1);
expect(skipped[0].path).toBe('b.ts');
expect(skipped[0].reason).toContain('calls');
});
it('never recurses into the skippedPaths field it populates', () => {
const result: Record<string, unknown> = {
nodes: [{ id: 'n', properties: { filePath: 'a.ts' } }],
skippedPaths: [{ path: 'prior.ts', reason: 'earlier sub-batch' }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const before = result.skippedPaths;
makeWorkerResultCloneSafe(result, opts);
expect(result.skippedPaths).toBe(before); // untouched
});
});
// U1 (#2112): the sanitizer must not recurse to a stack overflow on a deeply
// nested record — an over-deep subtree is bounded (treated non-cloneable) and
// the result is salvaged rather than the sanitizer throwing and re-arming the
// cascade it exists to prevent.
describe('bounded recursion depth', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
// Build a plain-object chain `{ child: { child: { … } } }` of the given
// depth with a non-cloneable function at the bottom.
const deepChainWithFn = (depth: number): Record<string, unknown> => {
let node: Record<string, unknown> = { leaked: () => 1 };
for (let i = 0; i < depth; i++) node = { child: node };
return node;
};
it('salvages a deeply-nested non-cloneable record without throwing RangeError', () => {
const result: Record<string, unknown> = {
nodes: [{ id: 'deep', filePath: 'deep.ts', tree: deepChainWithFn(5000) }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
// Must not throw (no RangeError escaping the sanitizer)...
expect(() => makeWorkerResultCloneSafe(result, opts)).not.toThrow();
// ...and the rewritten result is deliverable across postMessage.
expect(isStructuredCloneable(result)).toBe(true);
});
it('a shallow result is unaffected by the depth bound', () => {
const nodes = [{ id: 'n', properties: { filePath: 'a.ts', name: 'ok' } }];
const result: Record<string, unknown> = {
nodes,
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped).toEqual([]);
expect(result.nodes).toBe(nodes); // identity preserved, no needless copy
});
});
// U2 (#2112): two sanitizer-defeat vectors that previously let the re-post
// throw — a throwing getter and a detached ArrayBuffer/view.
describe('sanitizer-defeat hardening', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('drops a throwing getter and delivers the rest of the record', () => {
const el: Record<string, unknown> = { id: 'g', filePath: 'g.ts', name: 'keep' };
Object.defineProperty(el, 'boom', {
enumerable: true,
get() {
throw new Error('getter boom');
},
});
const result: Record<string, unknown> = {
nodes: [el],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
expect(() => makeWorkerResultCloneSafe(result, opts)).not.toThrow();
expect(isStructuredCloneable(result)).toBe(true);
const out = (result.nodes as Array<Record<string, unknown>>)[0];
expect(out.name).toBe('keep'); // legitimate data preserved
expect('boom' in out).toBe(false); // throwing getter stripped
});
it('drops a detached ArrayBuffer view and delivers the rest', () => {
const buf = new ArrayBuffer(8);
const view = new Uint8Array(buf);
structuredClone(buf, { transfer: [buf] }); // detaches buf → view is now detached
const result: Record<string, unknown> = {
nodes: [{ id: 'd', filePath: 'd.ts', data: view }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
expect((result.nodes as Array<Record<string, unknown>>)[0].data).toBeUndefined();
expect(skipped).toHaveLength(1);
});
it('does NOT drop a legitimately empty but live view (byteLength false-positive guard)', () => {
const nodes = [{ id: 'e', filePath: 'e.ts', data: new Uint8Array(0) }];
const result: Record<string, unknown> = {
nodes,
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped).toEqual([]);
expect(result.nodes).toBe(nodes); // untouched — empty live view clones fine
});
});
// R1 (#2135 tri-review): structuredClone serializes an array's NON-index
// own-enumerable properties and throws on a non-cloneable one. The index-only
// scan used to wave such an array through (`skipped: []`), leaving the result
// non-cloneable so the re-post threw and fail-closed → re-arming the cascade.
describe('array non-index own-enumerable properties', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('strips a non-index function property off a nested array and delivers the record', () => {
const tags: unknown[] & { meta?: unknown } = [1, 2, 3];
tags.meta = () => {}; // non-index own prop carrying a function
// sanity: this is the exact shape structuredClone rejects
expect(isStructuredCloneable({ tags })).toBe(false);
const result: Record<string, unknown> = {
nodes: [{ id: 'a', filePath: 'a.ts', tags }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true); // net no longer defeated
expect(skipped.length).toBeGreaterThan(0);
const outTags = (result.nodes as Array<{ tags: unknown[] & { meta?: unknown } }>)[0].tags;
expect(Array.from(outTags)).toEqual([1, 2, 3]); // indexed elements preserved
expect(outTags.meta).toBeUndefined(); // the function was stripped
});
it('carries a CLONEABLE non-index property through the strip', () => {
const tags: unknown[] & { note?: unknown; bad?: unknown } = [1];
tags.note = 'keep'; // cloneable non-index prop — must survive
tags.bad = () => {}; // forces the array dirty
const result: Record<string, unknown> = {
nodes: [{ id: 'b', filePath: 'b.ts', tags }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
const outTags = (result.nodes as Array<{ tags: { note?: unknown; bad?: unknown } }>)[0].tags;
expect(outTags.note).toBe('keep'); // data prop carried onto the stripped copy
expect(outTags.bad).toBeUndefined();
});
});
// R2 (#2135 tri-review): a throw DURING the sanitizer's own structural
// enumeration (a throwing getter on a path-less element reached by
// findFilePath, or a Proxy structural trap reached by instanceof/Object.keys)
// used to escape makeWorkerResultCloneSafe to the fail-closed {type:'error'},
// re-arming the cascade. findFilePath now reads defensively, and each element's
// sanitize is wrapped to drop-on-throw.
describe('sanitizer-internal throw is contained (drop-on-throw)', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('a throwing getter at a non-path key on a PATH-LESS element does not escape', () => {
// No top-level path key → findFilePath falls to its generic sweep and
// (pre-fix) read the throwing getter, throwing out of the sanitizer.
const el: Record<string, unknown> = { id: 'p', name: 'keep' };
Object.defineProperty(el, 'boom', {
enumerable: true,
get() {
throw new RangeError('boom');
},
});
const result: Record<string, unknown> = {
nodes: [el],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
expect(() => makeWorkerResultCloneSafe(result, opts)).not.toThrow();
expect(isStructuredCloneable(result)).toBe(true);
const out = (result.nodes as Array<Record<string, unknown>>)[0];
expect(out.name).toBe('keep'); // legitimate data delivered
expect('boom' in out).toBe(false); // throwing getter stripped, not escaped
});
it('a Proxy with a throwing structural trap is dropped, clean siblings survive', () => {
const trap = new Proxy(
{ id: 'b' },
{
getPrototypeOf() {
throw new Error('structural trap');
},
},
);
const result: Record<string, unknown> = {
nodes: [{ id: 'a', filePath: 'a.ts' }, trap, { id: 'c', filePath: 'c.ts' }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 3,
};
let skipped: SkippedPath[] = [];
expect(() => {
skipped = makeWorkerResultCloneSafe(result, opts).skipped;
}).not.toThrow();
expect(isStructuredCloneable(result)).toBe(true);
const ids = (result.nodes as Array<{ id: string }>).map((n) => n.id);
expect(ids).toEqual(['a', 'c']); // trap element dropped, siblings preserved
expect(skipped.some((s) => s.reason.includes('sanitizer error'))).toBe(true);
});
});
// R3 (#2135 tri-review): the per-field loop rewrites ARRAY fields only. A
// final isStructuredCloneable(result) gate strips any remaining non-array
// field so "the result is cloneable after this call" is a hard postcondition
// regardless of future result-shape changes.
describe('non-array field safety gate', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('strips a non-cloneable value carried on a non-ARRAY result field', () => {
const result: Record<string, unknown> = {
nodes: [],
parsedFiles: [],
skippedLanguages: {},
fileCount: 0,
summary: { kept: 1, build: () => {} }, // non-array field, non-cloneable
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
expect((result.summary as { kept: number; build?: unknown }).kept).toBe(1);
expect((result.summary as { build?: unknown }).build).toBeUndefined();
expect(skipped.some((s) => s.reason.includes('summary'))).toBe(true);
});
it('is a no-op when the array loop already made the result cloneable', () => {
const result: Record<string, unknown> = {
nodes: [{ id: 'a', filePath: 'a.ts', leak: () => {} }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
// Only the array-element strip was recorded; the gate added no '(result)' entry.
expect(skipped.every((s) => s.path !== '(result)')).toBe(true);
});
});
// R12 (#2135 tri-review): the "dropped unsalvageable" branch — a dirty element
// whose stripped copy is STILL not structured-cloneable must be dropped (not
// delivered), so the re-post can't throw. Triggered here with a non-plain
// member that the strip-time probe sees as clean but that turns non-cloneable
// on the final post-strip verification (a stateful getter).
describe('unsalvageable element drop', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('drops an element that is still non-cloneable after stripping', () => {
let reads = 0;
class Blob {}
const blob = new Blob();
Object.defineProperty(blob, 'data', {
enumerable: true,
// Clean on the strip-time probe (kept by reference), a function on the
// post-strip verification probe → the cleaned element is unsalvageable.
get() {
reads++;
return reads === 1 ? 'ok' : () => {};
},
});
const result: Record<string, unknown> = {
nodes: [{ id: 'x', filePath: 'x.ts', leak: () => {}, blob }],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true); // run survives
expect((result.nodes as unknown[]).length).toBe(0); // unsalvageable element dropped
expect(skipped.some((s) => s.reason.includes('unsalvageable'))).toBe(true);
});
});
// U3 (#2112): a DAG-aliased record (the same subobject reached via two paths)
// carrying a non-cloneable must be stripped-and-KEPT, not over-dropped — the
// old shared-WeakSet returned the un-stripped original on revisit, failing the
// last-resort guard and dropping the whole record.
describe('DAG-aliased records (memoized strip copies)', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('keeps a DAG element whose shared subobject carries a non-cloneable value', () => {
const shared: Record<string, unknown> = { tag: 's', leaked: () => 1 };
const el: Record<string, unknown> = {
id: 'dag',
filePath: 'dag.ts',
left: shared,
right: shared,
};
const result: Record<string, unknown> = {
nodes: [el],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(isStructuredCloneable(result)).toBe(true);
const out = (result.nodes as Array<Record<string, unknown>>)[0];
// Record is KEPT (stripped), not dropped as "unsalvageable".
expect(out).toBeDefined();
expect(skipped[0].reason).toContain('stripped');
const left = out.left as Record<string, unknown>;
const right = out.right as Record<string, unknown>;
expect(left.tag).toBe('s'); // legitimate data preserved
expect(left.leaked).toBeUndefined(); // function value stripped to undefined
// DAG shape preserved — the two aliases resolve to the SAME stripped copy.
expect(left).toBe(right);
});
it('terminates on a self-referential (cyclic) record', () => {
const cyc: Record<string, unknown> = { id: 'c', filePath: 'c.ts', bad: () => 1 };
cyc.self = cyc;
const result: Record<string, unknown> = {
nodes: [cyc],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
expect(() => makeWorkerResultCloneSafe(result, opts)).not.toThrow();
expect(isStructuredCloneable(result)).toBe(true);
const out = (result.nodes as Array<Record<string, unknown>>)[0];
expect(out.self).toBe(out); // cycle preserved against the stripped copy
expect(out.bad).toBeUndefined(); // function value stripped to undefined
});
});
// U4 (#2112): single-pass scan rebuilds only the dirty array (from the first
// dirty element on), and leaves every clean array untouched by identity.
describe('single-pass identity preservation', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('reassigns only the dirty field; clean fields keep identity', () => {
const cleanSymbols = [{ id: 's', filePath: 's.ts' }];
const cleanPrefix = { id: 'n0', properties: { filePath: 'n0.ts' } };
const dirtyNodes = [
cleanPrefix,
{ id: 'n1', properties: { filePath: 'n1.ts', bad: () => 1 } },
];
const result: Record<string, unknown> = {
nodes: dirtyNodes,
symbols: cleanSymbols,
parsedFiles: [],
skippedLanguages: {},
fileCount: 2,
};
makeWorkerResultCloneSafe(result, opts);
expect(result.symbols).toBe(cleanSymbols); // clean field untouched (identity)
expect(result.nodes).not.toBe(dirtyNodes); // dirty field rebuilt
const outNodes = result.nodes as Array<Record<string, unknown>>;
expect(outNodes[0]).toBe(cleanPrefix); // clean prefix copied by reference
expect(isStructuredCloneable(result)).toBe(true);
});
});
// U7 (#2112): a ParsedNode is attributed to properties.filePath even when a
// sibling child also carries a path-like key — the generic sweep alone could
// return the wrong sibling's path.
describe('findFilePath attribution (via skip reporting)', () => {
const opts = {
dropWholeElement: new Set(['parsedFiles']),
skipFields: new Set(['skippedPaths']),
};
it('prefers properties.filePath over a sibling child path key', () => {
const result: Record<string, unknown> = {
nodes: [
{
id: 'n',
meta: { file: 'sibling-wrong.ts' }, // sibling child with a path-like key, declared first
properties: { filePath: 'right.ts', bad: () => 1 },
},
],
parsedFiles: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped).toHaveLength(1);
expect(skipped[0].path).toBe('right.ts'); // not 'sibling-wrong.ts'
});
it('uses a top-level filePath when present', () => {
const result: Record<string, unknown> = {
parsedFiles: [{ filePath: 'top.c', captureSideChannel: { leaked: () => 1 } }],
nodes: [],
skippedLanguages: {},
fileCount: 1,
};
const { skipped } = makeWorkerResultCloneSafe(result, opts);
expect(skipped[0].path).toBe('top.c');
});
});
// C12 (#2112): contract — a representative ParseWorkerResult must be
// structured-cloneable. Typed as ParseWorkerResult so a NEW field added to
// the result shape forces this test to be updated (compile error until it is),
// and the runtime assert catches a field whose type becomes non-cloneable.
describe('ParseWorkerResult clone contract', () => {
it('a representative result is structured-cloneable', () => {
const result: ParseWorkerResult = {
nodes: [
{
id: 'func:src/a.ts#foo',
label: 'Function',
properties: {
name: 'foo',
filePath: 'src/a.ts',
startLine: 1,
endLine: 3,
language:
'typescript' as ParseWorkerResult['nodes'][number]['properties']['language'],
isExported: true,
},
},
],
relationships: [],
symbols: [],
calls: [],
assignments: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
routerModuleAliases: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
fileScopeBindings: [],
parsedFiles: [],
skippedLanguages: { ada: 2 },
skippedPaths: [{ path: 'x.ts', reason: 'prior' }],
fileCount: 1,
};
expect(isStructuredCloneable(result)).toBe(true);
});
});
});

View file

@ -0,0 +1,73 @@
/**
* #2135 tri-review (R12): the parse-worker result merge unions the clone-safety
* `skippedPaths` across sub-batch results. mergeResult was extracted from the
* parse-worker entry module into result-merge.ts so it can be unit-tested here
* (a main-thread import of parse-worker.ts would run its MessagePort setup).
*/
import { describe, it, expect } from 'vitest';
import { mergeResult } from '../../src/core/ingestion/workers/result-merge.js';
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
function emptyResult(): ParseWorkerResult {
return {
nodes: [],
relationships: [],
symbols: [],
calls: [],
assignments: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
fileScopeBindings: [],
parsedFiles: [],
skippedLanguages: {},
fileCount: 0,
};
}
describe('mergeResult', () => {
it('unions skippedPaths across sub-batch results, initializing the target when absent', () => {
const target = emptyResult(); // no skippedPaths on the target (the `??=` path)
mergeResult(target, {
...emptyResult(),
skippedPaths: [{ path: 'a.ts', reason: 'r1' }],
fileCount: 1,
});
mergeResult(target, {
...emptyResult(),
skippedPaths: [{ path: 'b.ts', reason: 'r2' }],
fileCount: 1,
});
expect(target.skippedPaths).toEqual([
{ path: 'a.ts', reason: 'r1' },
{ path: 'b.ts', reason: 'r2' },
]);
expect(target.fileCount).toBe(2);
});
it('leaves skippedPaths undefined when no source carries any (no spurious empty array)', () => {
const target = emptyResult();
mergeResult(target, { ...emptyResult(), fileCount: 1 });
expect(target.skippedPaths).toBeUndefined();
});
it('also sums skippedLanguages and appends node arrays (sanity of the rest of the merge)', () => {
const target = { ...emptyResult(), skippedLanguages: { rust: 1 } };
mergeResult(target, {
...emptyResult(),
nodes: [
{ id: 'n', label: 'Function', properties: { name: 'n' } },
] as ParseWorkerResult['nodes'],
skippedLanguages: { rust: 2, go: 1 },
});
expect(target.skippedLanguages).toEqual({ rust: 3, go: 1 });
expect(target.nodes).toHaveLength(1);
});
});