Forward-looking plan for items deferred during the multi-PR refactor that split
packages/doenetml-worker-javascript/src/Dependencies.js into typed modules
under src/core/dependencies/. The structural split (file organisation +
Dependencies.d.ts removal) and the initial typing pass (removing every
// @ts-nocheck and tightening as far as the surrounding types currently
allow) are done. What remains is a mix of (a) further type tightening that
needs upstream type work first, (b) latent behaviour issues surfaced and
documented in code, and (c) small consistency cleanups.
Each section below lists the scope, blocker, and strategy (the concrete next step when someone picks it up).
Scope. src/core/dependencies/Dependency.ts carries an
[key: string]: any index signature so subclasses can set their dynamic
per-type fields (componentIdx, parentIdx, attributeName, staticValue,
childIndices, componentTypes, compositeIdx, replacementPrimitives,
previousReplacementPrimitives, downstreamPrimitives,
originalDownstreamVariableNamesByComponent, …). Every subclass relies on
that escape hatch, which means none of those fields are checked.
Blocker. The set of fields a given subclass touches isn't documented
anywhere except by reading the constructor / setUpParameters / getValue
bodies. Doing this safely needs one walk per subclass.
Strategy. Per file in src/core/dependencies/<topic>Dependencies.ts:
- List the fields the subclass reads and writes (search
this.<name>per class). - Add the fields as declared properties on the subclass (typed as
anyinitially is fine; the goal is documentation + autocomplete, not exhaustive narrowing). - Once a subclass declares all of its fields, remove the inherited
[key: string]: anyfromDependencyfor that subclass via a more specific shape — or leave the index signature onDependencyand rely on TS already preferring the declared field type.
Cheap interim win: drop the explicit let parent: any = ... / let composite: any = ... annotations in childDependencies.ts,
siblingAndValueDependencies.ts, and replacementDependencies.ts once
ComponentInstance.activeChildren and .replacements are tightened (see
CORE_REFACTOR_DEFERRED.md items #1–#2).
Scope. Dependency.getValue declares
Promise<any> because subclass overrides return narrower shapes:
- The base returns
{ value, changes, usedDefault }. DoenetAttributeDependency,ExtendingDependency,AttributePrimitiveDependency,ComponentIdentityDependency, and thegetValueoverrides inreplacementDependencies.ts/childDependencies.ts/stateVariableDependencies.tsall return shapes that dropusedDefault(callers cope because of falsy checks).
Blocker. Tightening to a discriminated union (Promise<{ value; changes; usedDefault?: any }>) is fine for the type, but a few overrides
also wrap value in [] vs return scalar / null, and the
returnSingleVariableValue / returnSingleComponent flow is still
runtime-only.
Strategy. Pick one of:
- Cheap. Add
usedDefault?: anyto the base return type and let TS permit overrides that omit it. This dropsPromise<any>to a real shape with no source-code changes elsewhere. - Right. Have each subclass return
{ value, changes, usedDefault: false }explicitly so the return type is consistent across the hierarchy. Then narrow the base type toPromise<{ value; changes; usedDefault }>. This costs one line per override but removes theany.
Scope. DependencyHandler declares several tables loosely:
circularCheckPassed: Record<string, boolean>— fine.dependencyTypes: Record<string, any>— values are dependency-class constructors; could beRecord<string, DependencyClass>once the registry'sDependencyClassis exported.resolveBlockers.neededToResolveandresolveBlockers.resolveBlockedBy— nested maps; runtime structure is well-known but never declared.attributeRefResolutionDependenciesByReferenced—Record<ComponentIdx, any>today.DependencyUpdateTriggersentries — every field isRecord<…, any>.
Blocker. Each of these is constructed across many call sites, and
narrowing requires walking each writer to confirm the shape. The "needed
to resolve" / "resolve blocked by" tables in particular are constructed
inside addBlocker / deleteFromNeededToResolve /
deleteFromResolveBlockedBy and read across half the file.
Strategy. Do one table at a time, each as its own small PR:
- Pick a table (e.g.
resolveBlockers.neededToResolve). - Define its shape as a named type (e.g.
type NeededToResolve = Record<ComponentIdx, Record<TypeBlocked, Record<StateVarKey, BlockerCode[]>>>). - Walk every writer and confirm it produces that shape.
- Walk every reader and adjust the index types it expects.
Items #2–#4 of CORE_REFACTOR_DEFERRED.md (the (repl: any) /
ComponentInstance.replacements work) is a similar shape and should land
first if both are scheduled — the typing on the leaf objects rolls up.
Scope. ~25 method parameters in DependencyHandler.ts are typed
: any:
setUpStateVariableDependencies({ ... }: any)deleteAllDownstreamDependencies({ ... }: any)deleteAllUpstreamDependencies({ ... }: any)addBlockersFromChangedStateVariableDependencies({ ... }: any)addBlockersFromChangedActiveChildren({ parent }: any)addBlocker({ ... }: any)processNewlyResolved({ ... }: any)resolveItem({ ... }: any)resolveStateVariablesIfReady({ ... }: any)resolveIfReady({ ... }: any)getNeededToResolve({ ... }: any)and thedelete*FromNeededToResolvefamily- … plus the matching helpers around
ResolveBlockedBy.
Each is structured: every caller passes the same field set
(blockerComponentIdx, blockerType, componentIdxBlocked, typeBlocked,
…). The argument shape is consistent enough to be a single named type.
Blocker. None — this is mechanical, just bulky. The reason it wasn't
done with the initial typing pass is that the union of accepted blocker
types (stateVariable | componentIdentity | recursiveStateVariable | expandComposite | recalculateDownstreamComponents | …) hadn't been
inventoried.
Strategy.
- Define
type BlockerInfo = { blockerComponentIdx: ComponentIdx; blockerType: BlockerType; blockerStateVariable?: string; blockerDependency?: string; … }. - Define
BlockerTypeandTypeBlockedas string literal unions covering every value passed at every call site. - Replace
: anywith the typed shape onaddBlocker,deleteFromNeededToResolve,deleteFromResolveBlockedBy, and thegetNeededToResolve/getResolveBlockedBy/checkIfHaveNeededToResolvehelpers. - The other methods (
resolveItem,resolveIfReady, etc.) get their own argument types in the same pass.
Scope. stateVariableDependencies.ts reads
stateVarInfo.indexAliases at runtime, but the StateVariableDescription
type in src/utils/componentInfoObjects.ts does not declare that field.
Worked around with a local as any cast and a comment pointing at this
deferred item.
Blocker. None for the type addition itself. A separate audit is needed
to confirm what the runtime shape of indexAliases actually is — it's
indexed by dimension number and yields an array of alias-name strings,
but the precise shape (and whether it's always present on array-state
variables) should be confirmed before locking it down.
Strategy.
- Walk every writer of
indexAliases(component-class definitions insrc/components/). - Add the inferred shape to
StateVariableDescriptionasindexAliases?: string[][](or whatever the audit lands on). - Drop the
as anycast instateVariableDependencies.ts.
Scope. src/core/dependencies/registry.ts declares its own
constructor-shape type:
type DependencyClass = (new (args: any) => any) & { dependencyType: string };instead of typeof Dependency. The reason: typeof Dependency requires
the subclass constructors to accept the strictly-typed
DependencyHandlerOptions-shaped argument, but every subclass currently
inherits the constructor unchanged from Dependency, and TS reports
"types of construct signatures are incompatible" when several declared
constructor properties differ.
Blocker. Same as #1 — once each subclass either declares its own
constructor explicitly with the full typed-args bag, or stops carrying
[key: string]: any, the inferred constructor signature aligns with
Dependency's.
Strategy. Defer until #1 lands; then drop the local
DependencyClass type in favour of typeof Dependency.
Scope. DependencyHandler.recordActualChangeInUpstreamDependencies
references an identifier upValuesChangedSub that is not declared
anywhere in the file. The original JS source had a Note (dated July 20, 2023) comment flagging this as suspected dead code that would throw
ReferenceError if reached, but all tests still passed. The TS port marks
the three reference sites with // @ts-expect-error so the file compiles
without changing behaviour.
Blocker. Determining whether the path is genuinely unreachable, vs a
real bug that hasn't fired in production, needs runtime instrumentation:
the path runs only when an upstream variable's valuesChanged map has
no entry for the changed varName and the component carries
stateVarAliases.
Strategy.
- Add a temporary
console.warn(or a counter onCore.diagnostics) at the entry to the offending block. - Run the full test suite + a representative document set; confirm zero hits.
- If clean: delete the dead code (and the
// @ts-expect-errormarkers). If hit: replaceupValuesChangedSubwith the intended variable (likelyupDep.valuesChanged[ind]) and add a regression test.
Scope. refResolutionDependencies.ts calls
this.resolveComponentsInPathIndices(composite.refResolution.originalPath, force), but the function never reads its second argument. The TS port
declares the parameter as _force?: boolean to compile.
Blocker. Whether force is supposed to be threaded through to
this.dependencyHandler.core.resolveItem calls inside the function isn't
clear from the caller's intent — force matters in
determineDownstreamComponents for unblocking circular resolution, but
the inner resolve calls don't currently take it.
Strategy.
- Read
RefResolutionDependency.determineDownstreamComponentsto see whatforceis supposed to mean at this layer. - Either drop the unused argument from every caller, or thread
forceinto the innerresolveItemcalls and drop the underscore prefix.
Scope. refResolutionDependencies.ts invokes
this.dependencyHandler.core.resolvePath!({…}). resolvePath is declared
optional on Core (and on CoreOptions) because it's set lazily during
initialisation. Every other caller in the codebase already assumes it's
present.
Blocker. Tightening Core's type means deciding when in the lifecycle
resolvePath is guaranteed to be set, then either:
- Splitting
Coreinto aCoreUninitialised/CoreReadydiscriminated union, or - Setting
resolvePathto a no-op default in the constructor and making the public field non-optional.
Strategy. Take this on with the same pass that addresses items #1–#2
of CORE_REFACTOR_DEFERRED.md (wider Core typing). Once the field is
non-optional, drop the ! here.
Scope. Two helper closures inside DependencyHandler —
deleteBlockerTypeAndCode (in deleteFromNeededToResolve) and
deleteTypeAndCodeBlocked (in deleteFromResolveBlockedBy) — were
written as let foo = function (neededObj) { … }.bind(this); in the JS
source. The TS port rewrote them as arrow functions so this resolves
lexically, and dropped the trailing .bind(this). No behaviour change.
Strategy. No follow-up needed — listed here only so reviewers auditing the diff can see that the change is intentional and runtime- identical.
Scope. In stateVariableDependencies.ts (the returnSingleVariableValue
branch of getValue), the collapse step reads:
if (changes.valuesChanged && changes.valuesChanged[0] && changes.valuesChanged[0][0]) {
changes.valuesChanged = changes.valuesChanged[0][0];
}The inner index [0] is numeric, but the populated keys at that level are
state-variable names (strings like "value"), so the inner predicate is
always falsy and the collapse never fires. Consumers cope because
changes.valuesChanged is then left as the outer object and downstream
callers tolerate the wider shape. Preserved verbatim from
Dependencies.js:4302-4305.
Blocker. Determining the intended shape requires walking the consumers
that read changes.valuesChanged after a returnSingleVariableValue call
to confirm what shape they actually expect.
Strategy. Mirror the normalization logic used in Dependency.getValue
(returnSingleVariableValue branch in Dependency.ts): index by the
output variable name (stateVariables[0] / nameForOutput) instead of
[0]. Add a focused test that exercises the collapse path to lock the
shape down before the change.
12. addUpdateTriggerForMissingComponent / deleteUpdateTriggerForMissingComponent are async without awaits
Scope. Both helpers on Dependency are declared async but contain
no await and have no Promise-returning calls. Many call sites invoke
them without await (e.g. Dependency.deleteFromUpdateTriggers, plus
~10 sites in refResolutionDependencies.ts and the original JS).
Preserved verbatim from Dependencies.js:3696, 3714.
Blocker. Removing async is mechanical, but every call site needs
to be reviewed to confirm none rely on the function returning a Promise
(e.g. chaining .then, passing to Promise.all). A grep for
await this.addUpdateTriggerForMissingComponent /
.then( after these calls is sufficient.
Strategy.
- Walk every caller; confirm none use the Promise return value.
- Drop
asyncfrom both methods. Return type becomesvoid. - Optional: drop the matching
awaitfrom any call site that had one (TS allowsawaiton non-Promises but it adds a needless microtask).
Scope. Dependency.initialize (Dependency.ts:183) does
delete this.upValuesChanged; in the no-downstream-variables branch.
There is no upValuesChanged field on Dependency; the only related
field is valuesChanged (set in the else branch on the next line).
The delete is therefore a no-op. Preserved verbatim from
Dependencies.js:2823.
Blocker. Determining whether the intended field is valuesChanged
(in which case the delete was meant to undo a previous assignment from
a re-initialize call) or whether the line is just stale dead code
needs a git log -p -S upValuesChanged walk through the JS history.
Strategy.
- Run
git log --all -S 'upValuesChanged'against the original file to find the commit that introduced the line. - Either rename to
valuesChangedif that was the intent, or delete the line outright if it has been dead since introduction.
Scope. childDependencies.ts:416-434 walks
childSource.shadows / parentSource.shadows chains for every entry
in activeChildrenMatched but never reads the resulting childSource
/ parentSource after the inner while. The loop has no observable
effect. Preserved verbatim from Dependencies.js:5440-5455.
Blocker. Risk of removal is non-zero: the loop walks live
_components references and could in theory be relied on for a side
effect during the walk (none is visible, but the original code may
have depended on materialising shadow components for downstream
resolution). A diagnostic counter to confirm the loop body is reached
in normal documents is the cheap safety check.
Strategy.
- Add a
console.warn(orCore.diagnosticscounter) inside thewhilebody. Run the targeted test set + a representative document run; confirm the body executes. - Once confirmed reached but observably effect-free, delete the loop. If the body never executes, delete on that basis instead.
Scope. refResolutionDependencies.ts:362-363 and 465 build
diagnostic messages by template-interpolating referenceText with a
literal $ prefix:
`No referent found for reference: \`$${referenceText}\``referenceText is sourced from getDoenetMLStringForReference() which
takes a substring(startOffset, endOffset) over the raw DoenetML. If
the captured offsets include the leading $ of a reference like $x,
the message renders as `$$x`. Preserved verbatim from
Dependencies.js:7460-7461, 7563. The TODO comment at line 357-359
already flags that these messages duplicate format_error_message of
ref_resolve.ts.
Blocker. Verifying which side of the boundary the $ lives on
needs a small test — render a reference that triggers NonUniqueReferent
or NoReferent and inspect the emitted diagnostic text.
Strategy. Pick one:
- Cheap. Add a test that exercises the diagnostic path; if the
output is
$$x, drop the$from the template. If it's$x, the code is correct and the comment can be added to clarify. - Right. Address the TODO at line 357-359: route the formatting
through
ref_resolve.ts'sformat_error_messageso the two message sources can't drift.
- Sequencing. Items #1, #5, and #6 cluster — fixing #1 unblocks #6
and lets #5 simplify. Items #3 and #4 are independent and can be done
per-table / per-method. Item #7 is its own diagnostic-style task. Item
#9 follows the wider Core typing initiative. Items #11–#15 surfaced
during PR review as latent issues preserved verbatim from the original
Dependencies.js; they're independent of the typing work and can be picked up in any order. #12 is the cheapest (mechanical), #14 the smallest behaviour-affecting change, #11 the highest-risk because consumers cope with the current shape. - Items intentionally out of this plan. Wider
ComponentInstancetyping,Corelifecycle modelling, and the inverse-definition value bag types are tracked inCORE_REFACTOR_DEFERRED.md. The dependencies layer benefits from those as a side effect; we shouldn't duplicate that work here. - Testing posture. Targeted tests
(
copying/extend_references.test.ts,copying/external_references.test.ts,copying/unlinked copy.test.ts,baseComponent/baseComponentProperties.test.ts,answerValidation/symbolicEquality.test.ts) exercise the dependency graph aggressively and ran clean for every step of this refactor. Future tightening passes should run that subset locally and let CI catch the rest.