@fjall/eslint-plugin 12.2.0 → 12.4.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.
package/index.js CHANGED
@@ -29,6 +29,8 @@ import noRawDbTransaction from "./no-raw-db-transaction.js";
29
29
  import noRawExitCode from "./no-raw-exit-code.js";
30
30
  import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
31
31
  import noSilentResultDiscard from "./no-silent-result-discard.js";
32
+ import noOptionalWarningEmission from "./no-optional-warning-emission.js";
33
+ import noUnguardedChmodInTests from "./no-unguarded-chmod-in-tests.js";
32
34
 
33
35
  export default {
34
36
  rules: {
@@ -64,6 +66,8 @@ export default {
64
66
  "no-raw-db-transaction": noRawDbTransaction,
65
67
  "no-raw-exit-code": noRawExitCode,
66
68
  "no-replacement-string-expansion": noReplacementStringExpansion,
67
- "no-silent-result-discard": noSilentResultDiscard
69
+ "no-silent-result-discard": noSilentResultDiscard,
70
+ "no-optional-warning-emission": noOptionalWarningEmission,
71
+ "no-unguarded-chmod-in-tests": noUnguardedChmodInTests
68
72
  }
69
73
  };
@@ -57,6 +57,22 @@
57
57
  * masking opportunity. Catching at the ctor closes the gap before
58
58
  * the value crosses the Result boundary.
59
59
  *
60
+ * Internally-masking sinks (callers pass RAW):
61
+ * - `emitWarning(...)` (@fjall/util/diagnostics) — the spine strips
62
+ * control sequences THEN masks inside the emitter, and hands
63
+ * masksInternally sinks the pre-mask spelling. Pre-masking at the call
64
+ * site would not leak but WOULD defeat the single-mask contract, so a
65
+ * raw error leaf reaching `emitWarning(...)` is exempt here.
66
+ *
67
+ * Spine-consumer exemption (useDiagnostics / subscribeWarnings):
68
+ * WarningEvent payloads delivered to subscribeWarnings listeners and the
69
+ * useDiagnostics hook are post-mask BY CONTRACT — the subscription taps
70
+ * after the mask (util/src/diagnostics). Consumers writing
71
+ * `event.message` into set*-named state sinks therefore need no call-site
72
+ * mask. The rule does not flag them (a WarningEvent binding is not an
73
+ * error-shaped leaf), but that is convention, not naming accident: treat
74
+ * spine payloads as post-mask, and do not re-mask them.
75
+ *
60
76
  * Complements `require-masked-log-payload`, which targets credential-prone
61
77
  * keys in 3rd-arg logger payload OBJECTS. This rule targets leaf access
62
78
  * shapes regardless of payload structure (template literals, direct
@@ -179,6 +195,10 @@ const MASKING_HELPER_CALLEES = new Set([
179
195
  "maskAndTruncate",
180
196
  "maskAndBound"
181
197
  ]);
198
+ // Sinks that mask INSIDE the sink (util/src/diagnostics emitWarning:
199
+ // strip-then-mask). Callers pass raw — a call-site mask would defeat the
200
+ // spine's single-mask contract, so reaching one of these exempts the leaf.
201
+ const INTERNALLY_MASKING_SINK_CALLEES = new Set(["emitWarning"]);
182
202
 
183
203
  /** @type {import('eslint').Rule.RuleModule} */
184
204
  export default {
@@ -565,6 +585,12 @@ function findEnclosingSink(leaf, context, visited, leafShape) {
565
585
  if (calleeName !== null && MASKING_HELPER_CALLEES.has(calleeName)) {
566
586
  return { kind: "exempt" };
567
587
  }
588
+ if (
589
+ calleeName !== null &&
590
+ INTERNALLY_MASKING_SINK_CALLEES.has(calleeName)
591
+ ) {
592
+ return { kind: "exempt" };
593
+ }
568
594
  if (calleeName !== null && INTERNAL_PASSTHROUGH_CALLEES.has(calleeName)) {
569
595
  return candidateSink ?? { kind: "exempt" };
570
596
  }
@@ -0,0 +1,519 @@
1
+ /**
2
+ * ESLint Rule: no-optional-warning-emission
3
+ *
4
+ * A warning-typed progress event emitted through an optional callback link
5
+ * (`callbacks.onProgress?.({ type: "warning", … })`) vanishes without trace
6
+ * when no caller wired the link — the emit-only-dead-warnings shape the
7
+ * diagnostics spine exists to close. Severity=warning diagnostics must reach
8
+ * a bound sink even when no surface registered a callback: absence of a
9
+ * consumer means the spine's default sink, never silence.
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.
17
+ *
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
+ *
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.
37
+ *
38
+ * Known limitations (deliberate, matching the plugin's untyped design):
39
+ * - Literal-keyed only: an event object built in a binding and passed
40
+ * through, or a `type` held in a variable, passes unexamined.
41
+ * - Message-only `onWarning?.(msg)` lanes are out of scope: the
42
+ * ProgressCallbacks.onWarning(message, proceed) consent flow must never be
43
+ * flattened onto the fire-and-forget spine, and without type information
44
+ * the rule cannot tell the two onWarning arities apart.
45
+ * - Non-callback-named emitters (`emit?.({ type: "warning" })`) pass — the
46
+ * spine's own sink plumbing legitimately holds that shape.
47
+ *
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.
51
+ */
52
+
53
+ const CALLBACK_RE = /^on[A-Z]/;
54
+
55
+ /**
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.
60
+ */
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
+ ];
238
+
239
+ /**
240
+ * Strip TS type-level wrappers so `"warning" as const` and
241
+ * `{ … } satisfies ProgressEvent` cannot evade the literal check.
242
+ */
243
+ function unwrapTypeExpressions(node) {
244
+ let current = node;
245
+ while (
246
+ current.type === "TSAsExpression" ||
247
+ current.type === "TSSatisfiesExpression" ||
248
+ current.type === "TSNonNullExpression" ||
249
+ current.type === "TSTypeAssertion"
250
+ ) {
251
+ current = current.expression;
252
+ }
253
+ return current;
254
+ }
255
+
256
+ /**
257
+ * True when the invocation can short-circuit silently: the call itself is
258
+ * `?.()`, or an optional member link sits in ITS chain (`callbacks?.on…(…)`).
259
+ * A parenthesised chain in callee position (`(a?.b)()`) ends the chain — an
260
+ * absent link there throws rather than skips, so it is not this defect.
261
+ */
262
+ function invocationCanSilentlySkip(node) {
263
+ if (node.optional) return true;
264
+ let current = node.callee;
265
+ while (current.type === "MemberExpression") {
266
+ if (current.optional) return true;
267
+ current = current.object;
268
+ }
269
+ return false;
270
+ }
271
+
272
+ /** Terminal callee name: bare identifier, or member property. */
273
+ function terminalCalleeName(callee) {
274
+ let current = callee;
275
+ if (current.type === "ChainExpression") current = current.expression;
276
+ if (current.type === "Identifier") return current.name;
277
+ if (
278
+ current.type === "MemberExpression" &&
279
+ !current.computed &&
280
+ current.property.type === "Identifier"
281
+ ) {
282
+ return current.property.name;
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /** Non-computed property whose key spells `name`. */
288
+ function isPropertyNamed(property, name) {
289
+ if (property.type !== "Property" || property.computed) return false;
290
+ if (property.key.type === "Identifier") return property.key.name === name;
291
+ if (property.key.type === "Literal") return property.key.value === name;
292
+ return false;
293
+ }
294
+
295
+ /**
296
+ * First argument object literal carrying `type: "warning"`, else null.
297
+ */
298
+ function findWarningEventLiteral(node) {
299
+ for (const rawArgument of node.arguments) {
300
+ 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;
307
+ }
308
+ }
309
+ }
310
+ return null;
311
+ }
312
+
313
+ /**
314
+ * Name of a function node, when one is derivable: own id, variable binding,
315
+ * object-property key, class-method key, or assignment target.
316
+ */
317
+ function functionName(fn) {
318
+ if (
319
+ (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression") &&
320
+ fn.id
321
+ ) {
322
+ return fn.id.name;
323
+ }
324
+ const parent = fn.parent;
325
+ if (!parent) return null;
326
+ if (
327
+ parent.type === "VariableDeclarator" &&
328
+ parent.init === fn &&
329
+ parent.id.type === "Identifier"
330
+ ) {
331
+ return parent.id.name;
332
+ }
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
+ }
338
+ }
339
+ if (
340
+ parent.type === "MethodDefinition" &&
341
+ parent.value === fn &&
342
+ !parent.computed &&
343
+ parent.key.type === "Identifier"
344
+ ) {
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
+ }
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /**
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.
364
+ */
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;
377
+ }
378
+ return null;
379
+ }
380
+
381
+ /**
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.
385
+ */
386
+ function isWithinCalleeArgument(node, calleeName) {
387
+ let previous = node;
388
+ let current = node.parent;
389
+ while (current) {
390
+ if (
391
+ (current.type === "CallExpression" || current.type === "NewExpression") &&
392
+ previous !== current.callee &&
393
+ terminalCalleeName(current.callee) === calleeName
394
+ ) {
395
+ return true;
396
+ }
397
+ previous = current;
398
+ current = current.parent;
399
+ }
400
+ return false;
401
+ }
402
+
403
+ /**
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.
407
+ */
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;
419
+ }
420
+ }
421
+ return false;
422
+ }
423
+
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;
433
+ if (
434
+ entry.enclosingFunction !== undefined &&
435
+ nearestNamedEnclosingFunction(node) !== entry.enclosingFunction
436
+ ) {
437
+ continue;
438
+ }
439
+ if (
440
+ entry.withinCallee !== undefined &&
441
+ !isWithinCalleeArgument(node, entry.withinCallee)
442
+ ) {
443
+ continue;
444
+ }
445
+ if (
446
+ entry.messageProperty !== undefined &&
447
+ !messageReadsProperty(eventLiteral, entry.messageProperty)
448
+ ) {
449
+ continue;
450
+ }
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+
456
+ /** @type {import('eslint').Rule.RuleModule} */
457
+ export default {
458
+ meta: {
459
+ type: "problem",
460
+ docs: {
461
+ description:
462
+ "Disallow warning-typed events on optional callback links — an absent link silently swallows the warning; route it via the diagnostics spine",
463
+ category: "Possible Errors",
464
+ recommended: true
465
+ },
466
+ messages: {
467
+ 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."
469
+ },
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
+ ]
492
+ },
493
+
494
+ create(context) {
495
+ const allow = context.options[0]?.allow ?? [];
496
+
497
+ return {
498
+ CallExpression(node) {
499
+ if (!invocationCanSilentlySkip(node)) return;
500
+
501
+ const calleeName = terminalCalleeName(node.callee);
502
+ if (calleeName === null || !CALLBACK_RE.test(calleeName)) return;
503
+
504
+ const eventLiteral = findWarningEventLiteral(node);
505
+ if (eventLiteral === null) return;
506
+
507
+ if (siteIsSanctioned(allow, context.filename, node, eventLiteral)) {
508
+ return;
509
+ }
510
+
511
+ context.report({
512
+ node,
513
+ messageId: "optionalWarningEmission",
514
+ data: { callee: calleeName }
515
+ });
516
+ }
517
+ };
518
+ }
519
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ESLint Rule: no-unguarded-chmod-in-tests
3
+ *
4
+ * Hosted CI (Buildkite agents) executes the test suite as ROOT, and root
5
+ * ignores file modes entirely: a fixture chmod-ed to 0o000 still reads
6
+ * fine, a 0o444 file still writes. A permission-failure lane simulated via
7
+ * chmod therefore inverts under root — the code under test succeeds where
8
+ * the test expects failure, and the lane either fails in CI only or
9
+ * silently stops testing anything.
10
+ *
11
+ * Decision rule (the report message teaches the same thing):
12
+ * - PREFER injecting the failure at the first-party fs seam — a
13
+ * passthrough vi.mock of the fs module (importOriginal spread) with a
14
+ * per-test armed rejection such as
15
+ * `vi.mocked(readFile).mockRejectedValueOnce(eacces)` — deterministic
16
+ * in every environment, including root CI.
17
+ * - chmod + `it.skipIf(process.getuid?.() === 0)` is sanctioned ONLY
18
+ * where the failure must arise inside third-party code that cannot be
19
+ * seam-mocked (e.g. a glob library walking a mode-000 directory) — and
20
+ * it means the lane is untested in CI.
21
+ *
22
+ * Flagged shape: any call to a chmod-family function (chmod / chmodSync /
23
+ * fchmod / fchmodSync / lchmod / lchmodSync — bare import or member call
24
+ * such as `fs.chmodSync` / `fs.promises.chmod`) inside a test file
25
+ * (*.test.* / *.spec.*, or any file under a __tests__/ directory) whose
26
+ * file carries no root guard.
27
+ *
28
+ * Guard heuristic — deliberately coarse and FILE-scoped: any reference to
29
+ * `process.getuid` anywhere in the file counts as the guard. That matches
30
+ * both established house shapes — the inline
31
+ * `it.skipIf(process.getuid?.() === 0)` idiom and the named-constant form
32
+ * (`const runningAsRoot = typeof process.getuid === "function" &&
33
+ * process.getuid() === 0`, then `it.skipIf(runningAsRoot)`) — without
34
+ * per-call data-flow tracking, which would buy little: the defect class is
35
+ * a whole file written without root awareness, not a guarded file with one
36
+ * stray call. Four further coarse edges, all deliberate: a member call
37
+ * named `chmod` on a non-fs object is still flagged (in test files that
38
+ * name is near-certainly the fs API; rename the member for a genuine
39
+ * collision); the rule does not check that the guard's uid comparison
40
+ * is against 0 (a file that reads process.getuid at all has engaged with
41
+ * the root question); a guard imported from a shared helper is NOT
42
+ * recognised — inline the `process.getuid` reference in the file that
43
+ * chmods, which keeps the guard visible next to the hazard it excuses;
44
+ * and shell-mediated chmod (`execSync("chmod 000 …")`) is out of static
45
+ * reach — string sniffing would catch only literals while variable-carried
46
+ * commands escape, so that residue is left to the post-push CI-green check
47
+ * rather than to a porous lint arm that would imply coverage it lacks.
48
+ */
49
+
50
+ const CHMOD_FAMILY = new Set([
51
+ "chmod",
52
+ "chmodSync",
53
+ "fchmod",
54
+ "fchmodSync",
55
+ "lchmod",
56
+ "lchmodSync"
57
+ ]);
58
+
59
+ /** Test files only: *.test.* / *.spec.* basenames, or __tests__/ paths. */
60
+ function isTestFile(filename) {
61
+ const normalised = filename.replace(/\\/g, "/");
62
+ return (
63
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(normalised) ||
64
+ normalised.includes("/__tests__/")
65
+ );
66
+ }
67
+
68
+ /** Terminal callee name: bare identifier, or non-computed member property. */
69
+ function terminalCalleeName(callee) {
70
+ let current = callee;
71
+ if (current.type === "ChainExpression") current = current.expression;
72
+ if (current.type === "Identifier") return current.name;
73
+ if (
74
+ current.type === "MemberExpression" &&
75
+ !current.computed &&
76
+ current.property.type === "Identifier"
77
+ ) {
78
+ return current.property.name;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** Non-computed `process.getuid` access (plain or optional-chained). */
84
+ function isProcessGetuidReference(node) {
85
+ return (
86
+ !node.computed &&
87
+ node.object.type === "Identifier" &&
88
+ node.object.name === "process" &&
89
+ node.property.type === "Identifier" &&
90
+ node.property.name === "getuid"
91
+ );
92
+ }
93
+
94
+ /** @type {import('eslint').Rule.RuleModule} */
95
+ export default {
96
+ meta: {
97
+ type: "problem",
98
+ docs: {
99
+ description:
100
+ "Disallow chmod-family calls in test files with no process.getuid root guard — root CI ignores file modes, so chmod-simulated permission failures invert there",
101
+ category: "Possible Errors",
102
+ recommended: true
103
+ },
104
+ messages: {
105
+ unguardedChmod:
106
+ "`{{name}}` simulates a permission failure that root ignores — hosted CI runs tests as root, where a mode-000 file still reads fine, so this lane inverts in CI. Prefer injecting the failure at the first-party fs seam (passthrough vi.mock of the fs module, then vi.mocked(fn).mockRejectedValueOnce with a realistic EACCES error): deterministic in every environment, including root CI. chmod plus it.skipIf(process.getuid?.() === 0) is sanctioned ONLY where the failure must arise inside third-party code that cannot be seam-mocked — accepting that the lane goes untested in CI."
107
+ },
108
+ schema: []
109
+ },
110
+
111
+ create(context) {
112
+ if (!isTestFile(context.filename)) return {};
113
+
114
+ // The guard may appear before or after the chmod call (it is
115
+ // file-scoped), so collect calls and decide at Program:exit.
116
+ const chmodCalls = [];
117
+ let hasRootGuard = false;
118
+
119
+ return {
120
+ MemberExpression(node) {
121
+ if (isProcessGetuidReference(node)) hasRootGuard = true;
122
+ },
123
+
124
+ CallExpression(node) {
125
+ const name = terminalCalleeName(node.callee);
126
+ if (name !== null && CHMOD_FAMILY.has(name)) {
127
+ chmodCalls.push({ node, name });
128
+ }
129
+ },
130
+
131
+ "Program:exit"() {
132
+ if (hasRootGuard) return;
133
+ for (const { node, name } of chmodCalls) {
134
+ context.report({
135
+ node,
136
+ messageId: "unguardedChmod",
137
+ data: { name }
138
+ });
139
+ }
140
+ }
141
+ };
142
+ }
143
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "12.2.0",
3
+ "version": "12.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",