Skip to content

Commit 41f8a64

Browse files
committed
fix(check:settings-bind-window): only a trigger on the plugin context fires a kernel hook
A job or flow service's .trigger(name) reached from start() is not a hook; reading it as one would refuse a correct plugin with a remedy that does not apply. The context is tracked through the lifecycle methods' first parameter, helper parameters it is handed to, and this.X = ctx fields. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
1 parent 233ab76 commit 41f8a64

1 file changed

Lines changed: 59 additions & 11 deletions

File tree

‎scripts/check-settings-bind-window.mjs‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -495,8 +495,9 @@ function objectUnit(file, src, obj, fileFunctions) {
495495
* - ANY of them registered from inside a handler → that handler's own origin,
496496
* because it can only run once the registering handler has.
497497
*
498-
* A fired hook's name is a string literal, or a parameter the call site bound
499-
* to one (`emitCatalogEvent(ctx, 'app:registered', sys)` reaches
498+
* A hook is fired by `.trigger` on the PLUGIN CONTEXT (or `trigger.call` with
499+
* the context as `this`). Its name is a string literal, or a parameter the call
500+
* site bound to one (`emitCatalogEvent(ctx, 'app:registered', sys)` reaches
500501
* `trigger.call(ctx, event, payload)`); anything else is recorded UNRESOLVED,
501502
* and `audit()` refuses on it rather than derive a population it cannot name.
502503
*/
@@ -507,6 +508,42 @@ function preBindReads(unit, src, hookNames = new Set([READY_HOOK, ...Object.keys
507508
/** Local (block-scoped) function bindings seen anywhere along the walk. */
508509
const locals = new Map();
509510

511+
// The PLUGIN CONTEXT, by the names it travels under: the lifecycle methods'
512+
// first parameter, every helper parameter a call site hands it to, and every
513+
// `this.X = <context>` field. Only a `.trigger` on the context fires a kernel
514+
// hook — a job or flow service's `.trigger(name)` is not one, and reading it
515+
// as one would refuse a correct plugin with a remedy that does not apply.
516+
const contextNames = new Set();
517+
const contextFields = new Set();
518+
for (const phase of ['init', 'start']) {
519+
const p = unit.methods.get(phase)?.parameters?.[0];
520+
if (p && ts.isIdentifier(p.name)) contextNames.add(p.name.text);
521+
}
522+
const unwrap = (node) => {
523+
let n = node;
524+
while (n && (ts.isParenthesizedExpression(n) || ts.isAsExpression(n) ||
525+
ts.isNonNullExpression(n) || ts.isTypeAssertionExpression(n))) n = n.expression;
526+
return n;
527+
};
528+
const isContext = (node) => {
529+
const n = unwrap(node);
530+
if (!n) return false;
531+
if (ts.isIdentifier(n)) return contextNames.has(n.text);
532+
return ts.isPropertyAccessExpression(n) && n.expression.kind === ts.SyntaxKind.ThisKeyword &&
533+
contextFields.has(n.name.text);
534+
};
535+
for (const method of unit.methods.values()) {
536+
const scanFields = (node) => {
537+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
538+
ts.isPropertyAccessExpression(node.left) && node.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
539+
isContext(node.right)) {
540+
contextFields.add(node.left.name.text);
541+
}
542+
ts.forEachChild(node, scanFields);
543+
};
544+
if (method.body) scanFields(method.body);
545+
}
546+
510547
const walk = (fnNode, origin, visited, onHook, bindings = new Map()) => {
511548
if (!fnNode) return;
512549
// Keyed on the node AND its string bindings: one helper reached twice with
@@ -535,11 +572,13 @@ function preBindReads(unit, src, hookNames = new Set([READY_HOOK, ...Object.keys
535572
return undefined;
536573
};
537574

538-
/** The callee's parameters bound to whatever string arguments resolve. */
575+
/** The callee's parameters bound to whatever string arguments resolve —
576+
* and, unit-wide, the parameters it receives the context through. */
539577
const bindArgs = (target, args) => {
540578
const out = new Map();
541579
(target.parameters ?? []).forEach((p, i) => {
542580
if (!ts.isIdentifier(p.name)) return;
581+
if (isContext(args[i])) contextNames.add(p.name.text);
543582
const value = resolveString(args[i]);
544583
if (value !== undefined) out.set(p.name.text, value);
545584
});
@@ -600,12 +639,14 @@ function preBindReads(unit, src, hookNames = new Set([READY_HOOK, ...Object.keys
600639
if (handler) onHook(nameArg.text, handler);
601640
}
602641
}
603-
// `<anything>.trigger(name, …)` — the hook is FIRED here.
604-
if (callee.name.text === EMIT_CALLEE) recordEmit(node.arguments[0], node);
605-
// `trigger.call(thisArg, name, …)` — AppPlugin's spelling, which
642+
// `<context>.trigger(name, …)` — the hook is FIRED here.
643+
if (callee.name.text === EMIT_CALLEE && isContext(callee.expression)) {
644+
recordEmit(node.arguments[0], node);
645+
}
646+
// `trigger.call(<context>, name, …)` — AppPlugin's spelling, which
606647
// reads the method off the context first so a kernel without one
607-
// is a no-op rather than a throw.
608-
if (callee.name.text === 'call' &&
648+
// is a no-op rather than a throw. The context is the `this` argument.
649+
if (callee.name.text === 'call' && isContext(node.arguments[0]) &&
609650
((ts.isIdentifier(callee.expression) && callee.expression.text === EMIT_CALLEE) ||
610651
(ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === EMIT_CALLEE))) {
611652
recordEmit(node.arguments[1], node);
@@ -1448,6 +1489,7 @@ function selfTest() {
14481489
// emitCatalogEvent; the continuation is the over-budget seed path.
14491490
{
14501491
const { units } = auditSource(PROVIDER + `
1492+
function fireFrom(pluginCtx: PluginContext) { pluginCtx.trigger('x:renamed'); }
14511493
export class EmitterPlugin implements Plugin {
14521494
name = 'plugin.emitter';
14531495
private emitCatalogEvent(ctx: PluginContext, event: string, sys: any): void {
@@ -1457,9 +1499,13 @@ function selfTest() {
14571499
private emitWhenSettled(ctx: PluginContext, event: string): void {
14581500
settled.then(() => ctx.trigger(event));
14591501
}
1460-
async init(ctx: PluginContext) { await ctx.trigger('x:init', {}); }
1502+
async init(ctx: PluginContext) { this.ctx = ctx; await ctx.trigger('x:init', {}); }
14611503
async start(ctx: PluginContext) {
14621504
await ctx.trigger('x:direct', this.service);
1505+
this.ctx.trigger('x:stored');
1506+
fireFrom(ctx);
1507+
jobs.trigger('nightly-cleanup');
1508+
this.jobs.trigger(jobName);
14631509
const emitSettled = (overBudget: boolean) => {
14641510
const trigger = (ctx as any).trigger;
14651511
trigger.call(ctx, 'x:call', { overBudget });
@@ -1476,8 +1522,10 @@ function selfTest() {
14761522
`);
14771523
const derived = derivePreBindHooks(units);
14781524
const names = [...derived.fired.keys()].sort();
1479-
assert(JSON.stringify(names) === JSON.stringify(['x:call', 'x:direct', 'x:init', 'x:param', 'x:then', 'x:then-param']),
1480-
`the derivation reads every shipped fire-site spelling and nothing deferred (got ${names.join(', ')})`);
1525+
assert(JSON.stringify(names) === JSON.stringify(
1526+
['x:call', 'x:direct', 'x:init', 'x:param', 'x:renamed', 'x:stored', 'x:then', 'x:then-param']),
1527+
`the derivation reads every shipped fire-site spelling on the context, and nothing deferred or ` +
1528+
`off-context (got ${names.join(', ')})`);
14811529
assert(derived.fired.get('x:init')[0].origin === 'init-body', 'an init() fire site is recorded as init-body');
14821530
assert(derived.unresolved.length === 1 && derived.unresolved[0].text === 'eventName',
14831531
'a hook name the walk cannot resolve is recorded, not dropped');

0 commit comments

Comments
 (0)