@fjall/eslint-plugin 13.1.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,32 +8,42 @@
8
8
  * a bound sink even when no surface registered a callback: absence of a
9
9
  * consumer means the spine's default sink, never silence.
10
10
  *
11
- * Sanctioned fixes (named in the report message):
12
- * - `emitWarning(message, { scope })` (@fjall/util/diagnostics) wherever
13
- * the CLI owns the terminal end of the lane — the always-spine route;
14
- * - `emitStepWarnings(callbacks, step)` / `emitWarningEvent(callbacks,
15
- * message, opts)` (deploy-core orchestration/contextHelpers) at the
16
- * external-consumer boundary present→callback, absent→spine.
11
+ * Exactly two warning conventions exist (diagnostics-spine design, routing
12
+ * rule), and both satisfy this rule structurally — no allowlist remains:
13
+ * - Always-spine: `emitWarning(message, { scope })` (@fjall/util/
14
+ * diagnostics) wherever the CLI owns the terminal end of the lane.
15
+ * - External-boundary fallback: `emitWarningEvent`/`emitStepWarnings`
16
+ * (deploy-core orchestration/contextHelpers), serving the out-of-repo
17
+ * consumer of @fjall/deploy-core — present→callback, absent→spine. The
18
+ * helper's shape (guard the callback, call it, fall through to
19
+ * emitWarning) passes below precisely because its absent arm reaches
20
+ * the spine; the boundary, correctly implemented, is not an instance
21
+ * of the defect.
17
22
  *
18
- * Flagged shape: a callback invocation that can silently short-circuit (the
19
- * call itself is `?.()`, or any member link in its callee chain is optional)
20
- * whose callee names an `on[A-Z]`-shaped callback and whose arguments carry
21
- * an object literal with `type: "warning"` (TS `as`/`satisfies` wrappers
22
- * unwrapped, spread-extended literals included).
23
+ * Flagged shapes:
24
+ * 1. Optional-link emission: a callback invocation that can silently
25
+ * short-circuit (the call itself is `?.()`, or any member link in its
26
+ * callee chain is optional) whose callee names an `on[A-Z]`-shaped
27
+ * callback and whose arguments carry an object literal with
28
+ * `type: "warning"` (TS `as`/`satisfies` wrappers unwrapped,
29
+ * spread-extended literals included). A bare-identifier callee also
30
+ * counts as callback-named when its declaration initialiser reads an
31
+ * `on[A-Z]` callback (`const cb = options.onProgress`) — the alias
32
+ * does not launder the name.
33
+ * 2. Presence-guarded emission with no spine fallback: a PLAIN call of
34
+ * the same shape whose availability is guarded by the surrounding code
35
+ * (`if (cb) cb(…)`, `cb && cb(…)`, `cb ? cb(…) : …`, `typeof cb ===
36
+ * "function"`, `!!cb`, `Boolean(cb)`, including a guard on the callee
37
+ * chain's object) where the absent arm never reaches emitWarning/
38
+ * emitWarningEvent/emitStepWarnings — no else/alternate spine call,
39
+ * no fall-through spine call after the guard, no `||` spine
40
+ * continuation. The same silent drop as shape 1, laundered through a
41
+ * guard instead of `?.`.
23
42
  *
24
- * Deliberately kept wiring sites are declared in the rule-option allowlist
25
- * (`allow`), seeded from SANCTIONED_WARNING_EMISSION_SITES below enumerated
26
- * config data, reviewable in one place, in preference to inline disables.
27
- * Each entry names a file (path-suffix match on a path boundary) and
28
- * optionally narrows by:
29
- * - `enclosingFunction` — the nearest NAMED enclosing function; property-
30
- * assigned and variable-assigned arrows count as named, anonymous
31
- * wrappers (returned arrows, IIFEs) are skipped upward;
32
- * - `withinCallee` — the site sits inside an ARGUMENT of a call or `new`
33
- * of this name (callee identifier, or member property for method calls);
34
- * - `messageProperty` — the event literal's `message` value is a read of
35
- * this property (or a bare identifier of this name).
36
- * All fields given on an entry must match for the entry to sanction a site.
43
+ * Both shapes see through the `.call`/`.apply` spellings: the invoked
44
+ * function is the member one link up (`options.onProgress.call(…)` reads
45
+ * as onProgress; `emitWarning.call(…)` still counts as the spine), and an
46
+ * `.apply` argument array is scanned for the warning literal.
37
47
  *
38
48
  * Known limitations (deliberate, matching the plugin's untyped design):
39
49
  * - Literal-keyed only: an event object built in a binding and passed
@@ -44,197 +54,42 @@
44
54
  * the rule cannot tell the two onWarning arities apart.
45
55
  * - Non-callback-named emitters (`emit?.({ type: "warning" })`) pass — the
46
56
  * spine's own sink plumbing legitimately holds that shape.
57
+ * - Guard analysis is intra-function: a guarded emission deferred into a
58
+ * nested closure, and the inverted early-exit guard (`if (!cb) return;
59
+ * cb(…)`), pass unexamined.
60
+ * - Alias resolution reads declaration initialisers only: a callback
61
+ * assigned to a pre-declared binding after the fact (`let cb; cb =
62
+ * options.onProgress;`) passes unexamined. `.bind` aliases (`const cb =
63
+ * options.onProgress?.bind(x)`) pass too — the initialiser is a call
64
+ * result, which alias resolution does not follow.
65
+ * - A guard hoisted into a boolean binding (`const hasCb =
66
+ * !!options.onProgress; if (hasCb) options.onProgress(…)`) passes
67
+ * unexamined — the guard's key no longer names the callee chain.
68
+ * - Absent-path reachability treats a nested function as reachable only
69
+ * from an invoked position (IIFE callee or call argument): a spine call
70
+ * inside a merely-declared closure does not satisfy the fallback, and
71
+ * conversely a fallback wrapped deeper (e.g. a closure stored in an
72
+ * object property handed to a runner) is not credited — that shape flags
73
+ * and needs the direct spelling or a disable comment.
47
74
  *
48
- * Escape hatch: prefer an `allow` entry so the sanctioned set stays
49
- * enumerable; `// eslint-disable-next-line fjall/no-optional-warning-emission
50
- * -- <why>` remains available for genuinely local one-offs.
75
+ * Escape hatch: `// eslint-disable-next-line
76
+ * fjall/no-optional-warning-emission -- <why>` for genuinely local
77
+ * one-offs; there is no allow option.
51
78
  */
52
79
 
53
80
  const CALLBACK_RE = /^on[A-Z]/;
54
81
 
55
82
  /**
56
- * The sanctioned optional-warning wiring sites (diagnostics-spine design,
57
- * Component 8). Imported by cli/eslint.config.js and the root
58
- * eslint.config.mjs as the shared `allow` seed single source, so the two
59
- * configs cannot drift.
83
+ * Callees whose invocation counts as reaching the diagnostics spine on the
84
+ * absent-callback path: the always-spine emitter and the two
85
+ * external-boundary fallback helpers (which themselves fall back to
86
+ * emitWarning when no callback is wired).
60
87
  */
61
- export const SANCTIONED_WARNING_EMISSION_SITES = [
62
- // Attribution-degrade warning: emits into the caller-supplied onProgress
63
- // by contract (the caller decided the surface before invoking).
64
- {
65
- file: "cli/src/services/deployment/deploymentApiHelpers.ts",
66
- enclosingFunction: "warnUnattributedOperation"
67
- },
68
- // §5.5 gateUntrackedDeploy onWarning wiring — feeds the tracked-deploy
69
- // policy gate, whose warn-and-proceed arm renders on the caller's surface.
70
- {
71
- file: "cli/src/services/deployment/applicationDeployment.ts",
72
- withinCallee: "gateUntrackedDeploy"
73
- },
74
- // §5.6 accept-and-mark upgrade notice off the 201 — a single non-blocking
75
- // per-deploy notice on the deploy surface's own warning arm.
76
- {
77
- file: "cli/src/services/deployment/applicationDeployment.ts",
78
- enclosingFunction: "deployApplication",
79
- messageProperty: "upgradeNotice"
80
- },
81
- // The spine helpers' own internals: their present→callback arm IS the
82
- // external-boundary fallback contract the rule points everyone else at.
83
- {
84
- file: "deploy-core/src/orchestration/contextHelpers.ts",
85
- enclosingFunction: "emitWarningEvent"
86
- },
87
- {
88
- file: "deploy-core/src/orchestration/contextHelpers.ts",
89
- enclosingFunction: "emitStepWarnings"
90
- },
91
- {
92
- file: "cli/src/services/deployment/callbackShared.ts",
93
- enclosingFunction: "createOnLogHandler"
94
- },
95
-
96
- // Direct-producer lane — live-rendered via the surface builders' wired
97
- // onProgress; migrates in the direct-producer-migration follow-on.
98
- {
99
- file: "cli/src/services/deployment/applicationDeployment.ts",
100
- enclosingFunction: "warnBestEffort"
101
- },
102
- {
103
- file: "cli/src/services/deployment/applicationDeployment.ts",
104
- enclosingFunction: "deployApplication"
105
- },
106
- // DeploymentTracker onWarning wiring arrow.
107
- {
108
- file: "cli/src/services/deployment/applicationDeployment.ts",
109
- enclosingFunction: "onWarning"
110
- },
111
- {
112
- file: "cli/src/services/deployment/applicationDestruction.ts",
113
- enclosingFunction: "destroyApplication"
114
- },
115
- {
116
- file: "cli/src/services/deployment/applicationDestruction.ts",
117
- enclosingFunction: "onWarning"
118
- },
119
- {
120
- file: "cli/src/services/deployment/applicationDestruction.ts",
121
- enclosingFunction: "trackAndReturnFailure"
122
- },
123
- {
124
- file: "cli/src/services/deployment/applicationRestart.ts",
125
- enclosingFunction: "warnBestEffort"
126
- },
127
- {
128
- file: "cli/src/services/deployment/applicationRestart.ts",
129
- enclosingFunction: "onWarning"
130
- },
131
- // Quarantine/retained-bucket cleanup signals on both adapter copies.
132
- {
133
- file: "cli/src/services/deployment/baseCallbackBuilder.ts",
134
- enclosingFunction: "onStackCleanupProgress"
135
- },
136
- {
137
- file: "cli/src/services/deployment/InteractiveCallbackAdapter.ts",
138
- enclosingFunction: "onStackCleanupProgress"
139
- },
140
- {
141
- file: "cli/src/services/deployment/orgCallbackMapper.ts",
142
- enclosingFunction: "onCascadeLedger"
143
- },
144
-
145
- // Import lane — importOperation wires onProgress on every production
146
- // path; deliberately callback-based, revisited in the
147
- // direct-producer-migration follow-on.
148
- {
149
- file: "cli/src/services/import/ImportService.ts",
150
- enclosingFunction: "importResource"
151
- },
152
- {
153
- file: "cli/src/services/import/importAdoptionHelpers.ts",
154
- enclosingFunction: "generateAndWriteCode"
155
- },
156
- {
157
- file: "cli/src/services/monitoring/DiscoveryService.ts",
158
- enclosingFunction: "readS3BucketDetail"
159
- },
160
- {
161
- file: "cli/src/services/monitoring/DiscoveryService.ts",
162
- enclosingFunction: "discoverVpcs"
163
- },
164
-
165
- // Pre-existing stock, wiring unverified — catalogued as fresh census
166
- // input for the follow-on census (ADR); audited then migrated in the
167
- // direct-producer-migration follow-on.
168
- {
169
- file: "cli/src/commands/provisioning/restore.ts",
170
- enclosingFunction: "onWarning"
171
- },
172
- {
173
- file: "cli/src/operations/resources/secretsOperations.ts",
174
- enclosingFunction: "applyDeclaration"
175
- },
176
- {
177
- file: "cli/src/services/application/ApplicationDetectionHelpers.ts",
178
- enclosingFunction: "reportValidationNotRun"
179
- },
180
- {
181
- file: "cli/src/services/application/ApplicationDetectionHelpers.ts",
182
- enclosingFunction: "validateSecrets"
183
- },
184
- {
185
- file: "cli/src/services/auth/OidcBootstrapService.ts",
186
- enclosingFunction: "establishOidcTrust"
187
- },
188
- {
189
- file: "cli/src/services/aws-account/AwsAccountService.ts",
190
- enclosingFunction: "buildProviderAccountsFromConfig"
191
- },
192
- {
193
- file: "cli/src/services/aws/awsExecution.ts",
194
- enclosingFunction: "executeWithAwsCredentials"
195
- },
196
- {
197
- file: "cli/src/services/domain-dns/CliDomainDeployProvider.ts",
198
- enclosingFunction: "deployDomain"
199
- },
200
- {
201
- file: "cli/src/services/domain-dns/delegationService.ts",
202
- enclosingFunction: "deployPhase"
203
- },
204
- {
205
- file: "cli/src/services/organisation/OrganisationDeployService.ts",
206
- enclosingFunction: "onWarn"
207
- },
208
- {
209
- file: "cli/src/services/organisation/OrganisationDestroyService.ts",
210
- enclosingFunction: "onWarning"
211
- },
212
- {
213
- file: "cli/src/services/organisation/OrganisationDestroyService.ts",
214
- enclosingFunction: "destroy"
215
- },
216
- {
217
- file: "cli/src/services/organisation/OrganisationSetupService.ts",
218
- enclosingFunction: "onWarning"
219
- },
220
- {
221
- file: "cli/src/services/organisation/OrganisationSetupService.ts",
222
- enclosingFunction: "runOrganisationSetup"
223
- },
224
- {
225
- file: "cli/src/services/organisation/OrganisationStandaloneHelpers.ts",
226
- enclosingFunction: "registerStandaloneAccount"
227
- },
228
-
229
- // Dormant reporter-warning twin: ProgressReporter.warning has no live
230
- // production instantiation post-tap; kept until the reporter-lane-reroute
231
- // follow-on (a diagnostics import into the types module would be wrong
232
- // layering).
233
- {
234
- file: "deploy-core/src/types/ProgressEvent.ts",
235
- enclosingFunction: "warning"
236
- }
237
- ];
88
+ const SPINE_CALLEES = new Set([
89
+ "emitWarning",
90
+ "emitWarningEvent",
91
+ "emitStepWarnings"
92
+ ]);
238
93
 
239
94
  /**
240
95
  * Strip TS type-level wrappers so `"warning" as const` and
@@ -246,7 +101,8 @@ function unwrapTypeExpressions(node) {
246
101
  current.type === "TSAsExpression" ||
247
102
  current.type === "TSSatisfiesExpression" ||
248
103
  current.type === "TSNonNullExpression" ||
249
- current.type === "TSTypeAssertion"
104
+ current.type === "TSTypeAssertion" ||
105
+ current.type === "ChainExpression"
250
106
  ) {
251
107
  current = current.expression;
252
108
  }
@@ -269,7 +125,12 @@ function invocationCanSilentlySkip(node) {
269
125
  return false;
270
126
  }
271
127
 
272
- /** Terminal callee name: bare identifier, or member property. */
128
+ /**
129
+ * Terminal callee name: bare identifier, or member property. The
130
+ * `.call`/`.apply` spellings invoke their OBJECT, so they are named one
131
+ * link up when the object has a stable name (falling back to the literal
132
+ * property name when it does not).
133
+ */
273
134
  function terminalCalleeName(callee) {
274
135
  let current = callee;
275
136
  if (current.type === "ChainExpression") current = current.expression;
@@ -279,6 +140,10 @@ function terminalCalleeName(callee) {
279
140
  !current.computed &&
280
141
  current.property.type === "Identifier"
281
142
  ) {
143
+ if (current.property.name === "call" || current.property.name === "apply") {
144
+ const objectName = terminalCalleeName(current.object);
145
+ if (objectName !== null) return objectName;
146
+ }
282
147
  return current.property.name;
283
148
  }
284
149
  return null;
@@ -292,18 +157,44 @@ function isPropertyNamed(property, name) {
292
157
  return false;
293
158
  }
294
159
 
160
+ /** Object literal carrying `type: "warning"`? */
161
+ function isWarningEventLiteral(argument) {
162
+ if (argument.type !== "ObjectExpression") return false;
163
+ for (const property of argument.properties) {
164
+ if (!isPropertyNamed(property, "type")) continue;
165
+ const value = unwrapTypeExpressions(property.value);
166
+ if (value.type === "Literal" && value.value === "warning") {
167
+ return true;
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /** True when the call is spelled through `.apply` (array-of-args form). */
174
+ function isApplySpelling(callee) {
175
+ let current = callee;
176
+ if (current.type === "ChainExpression") current = current.expression;
177
+ return (
178
+ current.type === "MemberExpression" &&
179
+ !current.computed &&
180
+ current.property.type === "Identifier" &&
181
+ current.property.name === "apply"
182
+ );
183
+ }
184
+
295
185
  /**
296
- * First argument object literal carrying `type: "warning"`, else null.
186
+ * First argument object literal carrying `type: "warning"`, else null. For
187
+ * the `.apply` spelling the argument array's elements are scanned too.
297
188
  */
298
189
  function findWarningEventLiteral(node) {
299
190
  for (const rawArgument of node.arguments) {
300
191
  const argument = unwrapTypeExpressions(rawArgument);
301
- if (argument.type !== "ObjectExpression") continue;
302
- for (const property of argument.properties) {
303
- if (!isPropertyNamed(property, "type")) continue;
304
- const value = unwrapTypeExpressions(property.value);
305
- if (value.type === "Literal" && value.value === "warning") {
306
- return argument;
192
+ if (isWarningEventLiteral(argument)) return argument;
193
+ if (argument.type === "ArrayExpression" && isApplySpelling(node.callee)) {
194
+ for (const rawElement of argument.elements) {
195
+ if (rawElement === null) continue;
196
+ const element = unwrapTypeExpressions(rawElement);
197
+ if (isWarningEventLiteral(element)) return element;
307
198
  }
308
199
  }
309
200
  }
@@ -311,145 +202,321 @@ function findWarningEventLiteral(node) {
311
202
  }
312
203
 
313
204
  /**
314
- * Name of a function node, when one is derivable: own id, variable binding,
315
- * object-property key, class-method key, or assignment target.
205
+ * True when a bare-identifier callee aliases an `on[A-Z]`-named callback:
206
+ * its resolved declaration's initialiser is (a member read of) a
207
+ * callback-named expression — `const cb = options.onProgress` /
208
+ * `= callbacks?.onProgress` / `= onProgress`. Declaration initialisers
209
+ * only; later reassignment is a documented pass.
316
210
  */
317
- function functionName(fn) {
318
- if (
319
- (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression") &&
320
- fn.id
321
- ) {
322
- return fn.id.name;
211
+ function aliasesCallbackName(callee, sourceCode) {
212
+ let calleeNode = callee;
213
+ if (calleeNode.type === "ChainExpression") {
214
+ calleeNode = calleeNode.expression;
323
215
  }
324
- const parent = fn.parent;
325
- if (!parent) return null;
216
+ // `cb.call(…)`/`cb.apply(…)` invoke the alias one link up.
326
217
  if (
327
- parent.type === "VariableDeclarator" &&
328
- parent.init === fn &&
329
- parent.id.type === "Identifier"
218
+ calleeNode.type === "MemberExpression" &&
219
+ !calleeNode.computed &&
220
+ calleeNode.property.type === "Identifier" &&
221
+ (calleeNode.property.name === "call" ||
222
+ calleeNode.property.name === "apply")
330
223
  ) {
331
- return parent.id.name;
224
+ calleeNode = calleeNode.object;
332
225
  }
333
- if (parent.type === "Property" && parent.value === fn && !parent.computed) {
334
- if (parent.key.type === "Identifier") return parent.key.name;
335
- if (parent.key.type === "Literal" && typeof parent.key.value === "string") {
336
- return parent.key.value;
337
- }
226
+ if (calleeNode.type !== "Identifier") return false;
227
+
228
+ let scope = sourceCode.getScope(calleeNode);
229
+ let variable = null;
230
+ while (scope !== null && variable === null) {
231
+ variable = scope.variables.find((v) => v.name === calleeNode.name) ?? null;
232
+ scope = scope.upper;
338
233
  }
234
+ if (variable === null) return false;
235
+
236
+ for (const def of variable.defs) {
237
+ if (def.type !== "Variable" || def.node.init === null) continue;
238
+ const init = unwrapTypeExpressions(def.node.init);
239
+ const initName = terminalCalleeName(init);
240
+ if (initName !== null && CALLBACK_RE.test(initName)) return true;
241
+ }
242
+ return false;
243
+ }
244
+
245
+ /** Function-node predicate shared by the guard and reachability walks. */
246
+ function isFunctionNode(node) {
247
+ return (
248
+ node.type === "FunctionDeclaration" ||
249
+ node.type === "FunctionExpression" ||
250
+ node.type === "ArrowFunctionExpression"
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Canonical spelling of a presence-testable expression: an identifier,
256
+ * `this`, or a non-computed member chain over those. Optional links and TS
257
+ * wrappers are normalised away, so `callbacks?.onProgress` and
258
+ * `callbacks.onProgress` share a key. Null when the expression has no
259
+ * stable spelling (computed member, call result, …).
260
+ */
261
+ function expressionKey(node) {
262
+ const current = unwrapTypeExpressions(node);
263
+ if (current.type === "Identifier") return current.name;
264
+ if (current.type === "ThisExpression") return "this";
339
265
  if (
340
- parent.type === "MethodDefinition" &&
341
- parent.value === fn &&
342
- !parent.computed &&
343
- parent.key.type === "Identifier"
266
+ current.type === "MemberExpression" &&
267
+ !current.computed &&
268
+ current.property.type === "Identifier"
344
269
  ) {
345
- return parent.key.name;
346
- }
347
- if (parent.type === "AssignmentExpression" && parent.right === fn) {
348
- if (parent.left.type === "Identifier") return parent.left.name;
349
- if (
350
- parent.left.type === "MemberExpression" &&
351
- !parent.left.computed &&
352
- parent.left.property.type === "Identifier"
353
- ) {
354
- return parent.left.property.name;
355
- }
270
+ const objectKey = expressionKey(current.object);
271
+ return objectKey === null ? null : `${objectKey}.${current.property.name}`;
356
272
  }
357
273
  return null;
358
274
  }
359
275
 
360
276
  /**
361
- * Nearest NAMED enclosing function: anonymous wrappers are skipped upward so
362
- * a returned arrow attributes to the factory that returns it, while a
363
- * property-assigned arrow (`onWarning: (m) => …`) attributes to its key.
277
+ * True when establishing `guardKey` present also establishes the call path
278
+ * to `calleeKey`: the same expression, or a prefix of its member chain
279
+ * (guarding `callbacks` guards `callbacks.onProgress`).
364
280
  */
365
- function nearestNamedEnclosingFunction(node) {
366
- let current = node.parent;
367
- while (current) {
368
- if (
369
- current.type === "FunctionDeclaration" ||
370
- current.type === "FunctionExpression" ||
371
- current.type === "ArrowFunctionExpression"
372
- ) {
373
- const name = functionName(current);
374
- if (name !== null) return name;
375
- }
376
- current = current.parent;
281
+ function keyGuardsCallee(guardKey, calleeKey) {
282
+ if (guardKey === null) return false;
283
+ return calleeKey === guardKey || calleeKey.startsWith(`${guardKey}.`);
284
+ }
285
+
286
+ /** `undefined`, `null`, or `void …` — the absence spellings in guards. */
287
+ function isUndefinedLike(node) {
288
+ const value = unwrapTypeExpressions(node);
289
+ if (value.type === "Identifier" && value.name === "undefined") return true;
290
+ if (value.type === "Literal" && value.value === null) return true;
291
+ if (value.type === "UnaryExpression" && value.operator === "void") {
292
+ return true;
377
293
  }
378
- return null;
294
+ return false;
379
295
  }
380
296
 
381
297
  /**
382
- * True when `node` sits inside an ARGUMENT of a call/new whose callee spells
383
- * `calleeName`. Walks the full ancestor chain — the wiring arrow between the
384
- * emission and the sanctioning call is expected.
298
+ * True when one side of `binary` is `typeof <expr guarding calleeKey>` and
299
+ * the other is the string literal `literalValue`.
385
300
  */
386
- function isWithinCalleeArgument(node, calleeName) {
387
- let previous = node;
388
- let current = node.parent;
389
- while (current) {
301
+ function typeofComparedTo(binary, calleeKey, literalValue) {
302
+ const sides = [
303
+ [binary.left, binary.right],
304
+ [binary.right, binary.left]
305
+ ];
306
+ for (const [candidate, literal] of sides) {
307
+ const typeofSide = unwrapTypeExpressions(candidate);
308
+ const literalSide = unwrapTypeExpressions(literal);
390
309
  if (
391
- (current.type === "CallExpression" || current.type === "NewExpression") &&
392
- previous !== current.callee &&
393
- terminalCalleeName(current.callee) === calleeName
310
+ typeofSide.type === "UnaryExpression" &&
311
+ typeofSide.operator === "typeof" &&
312
+ keyGuardsCallee(expressionKey(typeofSide.argument), calleeKey) &&
313
+ literalSide.type === "Literal" &&
314
+ literalSide.value === literalValue
394
315
  ) {
395
316
  return true;
396
317
  }
397
- previous = current;
398
- current = current.parent;
399
318
  }
400
319
  return false;
401
320
  }
402
321
 
403
322
  /**
404
- * True when the event literal's `message` value reads `propertyName` a
405
- * non-computed member access (`startOutcome.upgradeNotice`) or a bare
406
- * identifier of that name.
323
+ * True when `test` (or an `&&`-conjunct of it) establishes that the callee
324
+ * spelled `calleeKey` is present/callable: bare truthiness, `!!` double
325
+ * negation, `Boolean(…)`, `!== undefined` / `!= null`, `typeof … !==
326
+ * "undefined"`, or `typeof … === "function"` — over the callee expression
327
+ * itself or a prefix of its member chain.
407
328
  */
408
- function messageReadsProperty(eventLiteral, propertyName) {
409
- for (const property of eventLiteral.properties) {
410
- if (!isPropertyNamed(property, "message")) continue;
411
- const value = unwrapTypeExpressions(property.value);
412
- if (value.type === "Identifier") return value.name === propertyName;
413
- if (
414
- value.type === "MemberExpression" &&
415
- !value.computed &&
416
- value.property.type === "Identifier"
417
- ) {
418
- return value.property.name === propertyName;
329
+ function testGuardsPresence(test, calleeKey) {
330
+ const condition = unwrapTypeExpressions(test);
331
+ if (condition.type === "LogicalExpression" && condition.operator === "&&") {
332
+ return (
333
+ testGuardsPresence(condition.left, calleeKey) ||
334
+ testGuardsPresence(condition.right, calleeKey)
335
+ );
336
+ }
337
+ if (condition.type === "UnaryExpression" && condition.operator === "!") {
338
+ const inner = unwrapTypeExpressions(condition.argument);
339
+ return (
340
+ inner.type === "UnaryExpression" &&
341
+ inner.operator === "!" &&
342
+ testGuardsPresence(inner.argument, calleeKey)
343
+ );
344
+ }
345
+ if (
346
+ condition.type === "CallExpression" &&
347
+ condition.callee.type === "Identifier" &&
348
+ condition.callee.name === "Boolean" &&
349
+ condition.arguments.length === 1
350
+ ) {
351
+ return testGuardsPresence(condition.arguments[0], calleeKey);
352
+ }
353
+ if (keyGuardsCallee(expressionKey(condition), calleeKey)) return true;
354
+ if (condition.type === "BinaryExpression") {
355
+ const { operator } = condition;
356
+ if (operator === "!==" || operator === "!=") {
357
+ if (
358
+ (isUndefinedLike(condition.right) &&
359
+ keyGuardsCallee(expressionKey(condition.left), calleeKey)) ||
360
+ (isUndefinedLike(condition.left) &&
361
+ keyGuardsCallee(expressionKey(condition.right), calleeKey))
362
+ ) {
363
+ return true;
364
+ }
365
+ if (typeofComparedTo(condition, calleeKey, "undefined")) return true;
366
+ }
367
+ if (operator === "===" || operator === "==") {
368
+ if (typeofComparedTo(condition, calleeKey, "function")) return true;
419
369
  }
420
370
  }
421
371
  return false;
422
372
  }
423
373
 
424
- /** Path-suffix match on a path boundary (or exact match). */
425
- function fileMatches(filename, entryFile) {
426
- const normalised = filename.replace(/\\/g, "/");
427
- return normalised === entryFile || normalised.endsWith(`/${entryFile}`);
428
- }
429
-
430
- function siteIsSanctioned(allow, filename, node, eventLiteral) {
431
- for (const entry of allow) {
432
- if (!fileMatches(filename, entry.file)) continue;
374
+ /**
375
+ * Innermost construct that presence-guards `calleeKey` around `node`: an
376
+ * IfStatement/ConditionalExpression whose test guards the callee with the
377
+ * node in its consequent, or a `&&` whose left guards it with the node on
378
+ * the right. Intra-function — the walk stops at the enclosing function
379
+ * boundary (a guarded emission deferred into a nested closure is a
380
+ * documented pass).
381
+ */
382
+ function findPresenceGuard(node, calleeKey) {
383
+ let previous = node;
384
+ let current = node.parent;
385
+ while (current && !isFunctionNode(current)) {
433
386
  if (
434
- entry.enclosingFunction !== undefined &&
435
- nearestNamedEnclosingFunction(node) !== entry.enclosingFunction
387
+ current.type === "IfStatement" &&
388
+ previous === current.consequent &&
389
+ testGuardsPresence(current.test, calleeKey)
436
390
  ) {
437
- continue;
391
+ return current;
438
392
  }
439
393
  if (
440
- entry.withinCallee !== undefined &&
441
- !isWithinCalleeArgument(node, entry.withinCallee)
394
+ current.type === "ConditionalExpression" &&
395
+ previous === current.consequent &&
396
+ testGuardsPresence(current.test, calleeKey)
442
397
  ) {
443
- continue;
398
+ return current;
444
399
  }
445
400
  if (
446
- entry.messageProperty !== undefined &&
447
- !messageReadsProperty(eventLiteral, entry.messageProperty)
401
+ current.type === "LogicalExpression" &&
402
+ current.operator === "&&" &&
403
+ previous === current.right &&
404
+ testGuardsPresence(current.left, calleeKey)
448
405
  ) {
449
- continue;
406
+ return current;
450
407
  }
408
+ previous = current;
409
+ current = current.parent;
410
+ }
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * True when a function-valued child sits in a position that invokes it (or
416
+ * hands it to something that may): the callee of a call (IIFE) or a call
417
+ * argument (callback given to a runner). A function value anywhere else — a
418
+ * declaration, a variable initialiser, an else-arm closure — executes
419
+ * nothing where it stands, so a spine call inside it must not count as the
420
+ * absent path reaching the spine.
421
+ */
422
+ function functionChildMayRun(parent, child) {
423
+ return (
424
+ parent.type === "CallExpression" &&
425
+ (parent.callee === child || parent.arguments.includes(child))
426
+ );
427
+ }
428
+
429
+ /**
430
+ * True when `node`'s subtree contains a REACHABLE call to a spine callee.
431
+ * Recursive descent over AST children; never follows `parent` links.
432
+ * Nested function bodies are descended only from an invoked position
433
+ * (see functionChildMayRun) — a merely-declared closure mentioning
434
+ * emitWarning reaches nothing at runtime.
435
+ */
436
+ function containsSpineCall(node) {
437
+ if (
438
+ node.type === "CallExpression" &&
439
+ SPINE_CALLEES.has(terminalCalleeName(node.callee) ?? "")
440
+ ) {
441
+ return true;
442
+ }
443
+ for (const key of Object.keys(node)) {
444
+ if (key === "parent") continue;
445
+ const value = node[key];
446
+ if (Array.isArray(value)) {
447
+ for (const child of value) {
448
+ if (
449
+ child !== null &&
450
+ typeof child === "object" &&
451
+ typeof child.type === "string" &&
452
+ (!isFunctionNode(child) || functionChildMayRun(node, child)) &&
453
+ containsSpineCall(child)
454
+ ) {
455
+ return true;
456
+ }
457
+ }
458
+ } else if (
459
+ value !== null &&
460
+ typeof value === "object" &&
461
+ typeof value.type === "string" &&
462
+ (!isFunctionNode(value) || functionChildMayRun(node, value)) &&
463
+ containsSpineCall(value)
464
+ ) {
465
+ return true;
466
+ }
467
+ }
468
+ return false;
469
+ }
470
+
471
+ /**
472
+ * True when the absent-callback path of `guard` can still reach the spine:
473
+ * the guard's own alternate branch carries a spine call, a statement after
474
+ * the guard (walking enclosing blocks up to the function boundary) does —
475
+ * the boundary-helper shape guards, calls, returns, and falls through to
476
+ * emitWarning — or a `||` continuation to the guard's right does.
477
+ * Errs towards passing: partial reachability on exotic control flow is
478
+ * accepted rather than over-flagged.
479
+ */
480
+ function absentPathReachesSpine(guard) {
481
+ if (
482
+ guard.type === "IfStatement" &&
483
+ guard.alternate !== null &&
484
+ containsSpineCall(guard.alternate)
485
+ ) {
486
+ return true;
487
+ }
488
+ if (
489
+ guard.type === "ConditionalExpression" &&
490
+ containsSpineCall(guard.alternate)
491
+ ) {
451
492
  return true;
452
493
  }
494
+
495
+ let previous = guard;
496
+ let current = guard.parent;
497
+ while (current && !isFunctionNode(current)) {
498
+ const body =
499
+ current.type === "BlockStatement" || current.type === "Program"
500
+ ? current.body
501
+ : current.type === "SwitchCase"
502
+ ? current.consequent
503
+ : null;
504
+ if (body !== null) {
505
+ const index = body.indexOf(previous);
506
+ for (let i = index + 1; i < body.length; i++) {
507
+ if (containsSpineCall(body[i])) return true;
508
+ }
509
+ } else if (
510
+ current.type === "LogicalExpression" &&
511
+ current.operator === "||" &&
512
+ previous === current.left &&
513
+ containsSpineCall(current.right)
514
+ ) {
515
+ return true;
516
+ }
517
+ previous = current;
518
+ current = current.parent;
519
+ }
453
520
  return false;
454
521
  }
455
522
 
@@ -459,58 +526,55 @@ export default {
459
526
  type: "problem",
460
527
  docs: {
461
528
  description:
462
- "Disallow warning-typed events on optional callback links — an absent link silently swallows the warning; route it via the diagnostics spine",
529
+ "Disallow warning-typed events on optional or presence-guarded callback links with no spine fallback — an absent link silently swallows the warning; route it via the diagnostics spine",
463
530
  category: "Possible Errors",
464
531
  recommended: true
465
532
  },
466
533
  messages: {
467
534
  optionalWarningEmission:
468
- "Warning-typed event rides an optional callback link (`{{callee}}`) — when the link is absent the warning vanishes silently. Emit via emitWarning(message, { scope }) (@fjall/util/diagnostics) where the CLI owns the lane, or emitStepWarnings/emitWarningEvent (deploy-core contextHelpers) at the external-consumer boundary. Deliberately kept wiring sites belong in this rule's `allow` option, not behind inline disables."
535
+ "Warning-typed event rides an optional callback link (`{{callee}}`) — when the link is absent the warning vanishes silently. Emit via emitWarning(message, { scope }) (@fjall/util/diagnostics) where the CLI owns the lane, or emitStepWarnings/emitWarningEvent (deploy-core contextHelpers) at the external-consumer boundary.",
536
+ guardedWarningEmission:
537
+ "Warning-typed event rides a presence-guarded callback (`{{callee}}`) whose absent arm never reaches the diagnostics spine — when the callback is absent the warning vanishes silently. Emit via emitWarning(message, { scope }) (@fjall/util/diagnostics) where the CLI owns the lane, or give the absent arm the external-boundary fallback shape (present→callback, absent→emitWarning; see deploy-core contextHelpers emitWarningEvent)."
469
538
  },
470
- schema: [
471
- {
472
- type: "object",
473
- properties: {
474
- allow: {
475
- type: "array",
476
- items: {
477
- type: "object",
478
- properties: {
479
- file: { type: "string" },
480
- enclosingFunction: { type: "string" },
481
- withinCallee: { type: "string" },
482
- messageProperty: { type: "string" }
483
- },
484
- required: ["file"],
485
- additionalProperties: false
486
- }
487
- }
488
- },
489
- additionalProperties: false
490
- }
491
- ]
539
+ schema: []
492
540
  },
493
541
 
494
542
  create(context) {
495
- const allow = context.options[0]?.allow ?? [];
496
-
497
543
  return {
498
544
  CallExpression(node) {
499
- if (!invocationCanSilentlySkip(node)) return;
500
-
501
545
  const calleeName = terminalCalleeName(node.callee);
502
- if (calleeName === null || !CALLBACK_RE.test(calleeName)) return;
546
+ if (calleeName === null) return;
547
+ if (
548
+ !CALLBACK_RE.test(calleeName) &&
549
+ !aliasesCallbackName(node.callee, context.sourceCode)
550
+ ) {
551
+ return;
552
+ }
503
553
 
504
554
  const eventLiteral = findWarningEventLiteral(node);
505
555
  if (eventLiteral === null) return;
506
556
 
507
- if (siteIsSanctioned(allow, context.filename, node, eventLiteral)) {
557
+ if (invocationCanSilentlySkip(node)) {
558
+ context.report({
559
+ node,
560
+ messageId: "optionalWarningEmission",
561
+ data: { callee: calleeName }
562
+ });
508
563
  return;
509
564
  }
510
565
 
566
+ // Hardening: a plain call laundered through a presence guard drops
567
+ // the warning just as silently when the absent arm has no spine
568
+ // fallback.
569
+ const calleeKey = expressionKey(node.callee);
570
+ if (calleeKey === null) return;
571
+ const guard = findPresenceGuard(node, calleeKey);
572
+ if (guard === null) return;
573
+ if (absentPathReachesSpine(guard)) return;
574
+
511
575
  context.report({
512
576
  node,
513
- messageId: "optionalWarningEmission",
577
+ messageId: "guardedWarningEmission",
514
578
  data: { callee: calleeName }
515
579
  });
516
580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "13.1.0",
3
+ "version": "14.1.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",