mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(tools): detect render-phase cycles and fix class-name JSDoc placement
Address ds-review-bot v5/v6 review round 6: - renderType tracks the active ancestor schemas by object identity (the frame stack is the DFS path). A stateful getter can mutate the graph after validation so a child returns an ancestor at render time; without this the walk pushed frames forever instead of degrading. A repeated ancestor now degrades to Any, honoring the never-throw contract; distinct nodes in a legitimately deep chain are different objects, so it stays O(1) per push and O(depth) memory. - The multiline allocateClassName JSDoc was still attached to the MAX_CLASS_NAME_BASE constant (a self-referential @link, and the function had no doc). Move the doc onto the function and give the constant its own one-liner. - Tests cover the post-validation cycle and a non-object render-time child; py-types.ts stays at 100% per-file coverage.
This commit is contained in:
@@ -122,6 +122,9 @@ function camelCase(raw: string): string {
|
||||
return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}`
|
||||
}
|
||||
|
||||
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
|
||||
const MAX_CLASS_NAME_BASE = 120
|
||||
|
||||
/**
|
||||
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
||||
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
||||
@@ -133,7 +136,6 @@ function camelCase(raw: string): string {
|
||||
* `2`, so a deep chain sharing one capped base stays O(1) per allocation
|
||||
* (amortized) instead of Θ(depth²) in time.
|
||||
*/
|
||||
const MAX_CLASS_NAME_BASE = 120
|
||||
function allocateClassName(base: string, state: RenderState): string {
|
||||
const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base
|
||||
let name = capped
|
||||
@@ -220,6 +222,15 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
const newFrame = (schema: unknown, className: string, validated: boolean): Frame =>
|
||||
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
||||
const frames: Frame[] = [newFrame(schema, className, false)]
|
||||
// Ancestor schemas by object identity — the frame stack IS the DFS path, so
|
||||
// this set holds exactly the current node's ancestors. A stateful getter can
|
||||
// mutate the graph after validation (an `items`/property that validated as a
|
||||
// scalar but returns an ancestor at render time); without this, the walk
|
||||
// would push frames forever. A repeated ancestor degrades to `Any` per the
|
||||
// never-throw contract. Distinct nodes in a legitimately deep chain are all
|
||||
// different objects, so this stays O(1) per push and O(depth) memory.
|
||||
const activeSchemas = new Set<object>()
|
||||
if (typeof schema === 'object' && schema !== null) activeSchemas.add(schema)
|
||||
let result: string | undefined
|
||||
// The no-throw contract must hold across the WHOLE walk, not just the root
|
||||
// validation: a hostile stateful getter (a `type` that returns a scalar on
|
||||
@@ -231,7 +242,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||
const finish = (type: string): void => {
|
||||
frames.pop()
|
||||
const popped = frames.pop()
|
||||
if (popped !== undefined && typeof popped.schema === 'object' && popped.schema !== null) {
|
||||
activeSchemas.delete(popped.schema)
|
||||
}
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) result = type
|
||||
else parent.childTypes.push(type)
|
||||
@@ -249,6 +263,18 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing python render child')
|
||||
frame.childIndex++
|
||||
// A child schema already on the active path is a cycle a post-
|
||||
// validation mutation introduced; degrade it to `Any` rather than
|
||||
// recurse forever. A fresh object joins the path (finish removes it);
|
||||
// a non-object child carries no identity to track.
|
||||
if (typeof child.schema === 'object' && child.schema !== null) {
|
||||
if (activeSchemas.has(child.schema)) {
|
||||
state.typing.add('Any')
|
||||
frame.childTypes.push('Any')
|
||||
continue
|
||||
}
|
||||
activeSchemas.add(child.schema)
|
||||
}
|
||||
frames.push(newFrame(child.schema, child.className, true))
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user