@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 +5 -1
- package/mask-error-message-at-boundary.js +26 -0
- package/no-optional-warning-emission.js +519 -0
- package/no-unguarded-chmod-in-tests.js +143 -0
- package/package.json +1 -1
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
|
+
};
|