react-native-nitro-logger 0.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.
Files changed (205) hide show
  1. package/LICENSE +20 -0
  2. package/NitroLogger.podspec +29 -0
  3. package/README.md +214 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
  10. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
  11. package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
  12. package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
  13. package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
  14. package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
  15. package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
  16. package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
  17. package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
  18. package/docs/PARITY.md +330 -0
  19. package/docs/PRIVACY.md +341 -0
  20. package/eslint-plugin/README.md +238 -0
  21. package/eslint-plugin/index.js +62 -0
  22. package/eslint-plugin/rules/literal-subsystem.js +102 -0
  23. package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
  24. package/eslint-plugin/rules/no-derived-correlation.js +171 -0
  25. package/eslint-plugin/rules/no-dynamic-message.js +60 -0
  26. package/eslint-plugin/shared.js +1948 -0
  27. package/ios/Gzip.swift +203 -0
  28. package/ios/HybridFileSink.swift +291 -0
  29. package/ios/HybridNativeConsoleSink.swift +20 -0
  30. package/ios/LogFileWriter.swift +1280 -0
  31. package/ios/LogSecureFile.swift +296 -0
  32. package/ios/LogWriterRegistry.swift +444 -0
  33. package/ios/NativeConsoleWriter.swift +180 -0
  34. package/lib/module/Logger.js +381 -0
  35. package/lib/module/Logger.js.map +1 -0
  36. package/lib/module/ScopedLogger.js +70 -0
  37. package/lib/module/ScopedLogger.js.map +1 -0
  38. package/lib/module/config.js +23 -0
  39. package/lib/module/config.js.map +1 -0
  40. package/lib/module/destinations/Batcher.js +647 -0
  41. package/lib/module/destinations/Batcher.js.map +1 -0
  42. package/lib/module/destinations/ConsoleDestination.js +51 -0
  43. package/lib/module/destinations/ConsoleDestination.js.map +1 -0
  44. package/lib/module/destinations/FileDestination.js +360 -0
  45. package/lib/module/destinations/FileDestination.js.map +1 -0
  46. package/lib/module/destinations/NativeConsoleDestination.js +170 -0
  47. package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
  48. package/lib/module/destinations/types.js +4 -0
  49. package/lib/module/destinations/types.js.map +1 -0
  50. package/lib/module/formatters/DefaultFormatter.js +136 -0
  51. package/lib/module/formatters/DefaultFormatter.js.map +1 -0
  52. package/lib/module/formatters/JsonLinesFormatter.js +162 -0
  53. package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
  54. package/lib/module/formatters/timestamp.js +15 -0
  55. package/lib/module/formatters/timestamp.js.map +1 -0
  56. package/lib/module/formatters/types.js +4 -0
  57. package/lib/module/formatters/types.js.map +1 -0
  58. package/lib/module/index.js +31 -0
  59. package/lib/module/index.js.map +1 -0
  60. package/lib/module/integrations/appState.js +81 -0
  61. package/lib/module/integrations/appState.js.map +1 -0
  62. package/lib/module/integrations/errorHandler.js +166 -0
  63. package/lib/module/integrations/errorHandler.js.map +1 -0
  64. package/lib/module/integrations/sanitizeError.js +238 -0
  65. package/lib/module/integrations/sanitizeError.js.map +1 -0
  66. package/lib/module/levels.js +35 -0
  67. package/lib/module/levels.js.map +1 -0
  68. package/lib/module/metadata.js +52 -0
  69. package/lib/module/metadata.js.map +1 -0
  70. package/lib/module/package.json +1 -0
  71. package/lib/module/privacy.js +349 -0
  72. package/lib/module/privacy.js.map +1 -0
  73. package/lib/module/specs/FileSink.nitro.js +4 -0
  74. package/lib/module/specs/FileSink.nitro.js.map +1 -0
  75. package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
  76. package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
  77. package/lib/module/types.js +4 -0
  78. package/lib/module/types.js.map +1 -0
  79. package/lib/module/utf8.js +42 -0
  80. package/lib/module/utf8.js.map +1 -0
  81. package/lib/typescript/package.json +1 -0
  82. package/lib/typescript/src/Logger.d.ts +122 -0
  83. package/lib/typescript/src/Logger.d.ts.map +1 -0
  84. package/lib/typescript/src/ScopedLogger.d.ts +36 -0
  85. package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
  86. package/lib/typescript/src/config.d.ts +12 -0
  87. package/lib/typescript/src/config.d.ts.map +1 -0
  88. package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
  89. package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
  90. package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
  91. package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
  92. package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
  93. package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
  94. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
  95. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
  96. package/lib/typescript/src/destinations/types.d.ts +19 -0
  97. package/lib/typescript/src/destinations/types.d.ts.map +1 -0
  98. package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
  99. package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
  100. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
  101. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
  102. package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
  103. package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
  104. package/lib/typescript/src/formatters/types.d.ts +27 -0
  105. package/lib/typescript/src/formatters/types.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +34 -0
  107. package/lib/typescript/src/index.d.ts.map +1 -0
  108. package/lib/typescript/src/integrations/appState.d.ts +33 -0
  109. package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
  110. package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
  111. package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
  112. package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
  113. package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
  114. package/lib/typescript/src/levels.d.ts +9 -0
  115. package/lib/typescript/src/levels.d.ts.map +1 -0
  116. package/lib/typescript/src/metadata.d.ts +23 -0
  117. package/lib/typescript/src/metadata.d.ts.map +1 -0
  118. package/lib/typescript/src/privacy.d.ts +103 -0
  119. package/lib/typescript/src/privacy.d.ts.map +1 -0
  120. package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
  121. package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
  122. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
  123. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
  124. package/lib/typescript/src/types.d.ts +37 -0
  125. package/lib/typescript/src/types.d.ts.map +1 -0
  126. package/lib/typescript/src/utf8.d.ts +15 -0
  127. package/lib/typescript/src/utf8.d.ts.map +1 -0
  128. package/nitro.json +33 -0
  129. package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
  130. package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
  131. package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
  132. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
  133. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
  134. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
  136. package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
  137. package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
  138. package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
  148. package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
  149. package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
  150. package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
  151. package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
  152. package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
  153. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
  154. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
  155. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
  156. package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
  157. package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
  158. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
  159. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
  160. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
  161. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
  162. package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
  163. package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
  164. package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
  165. package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
  166. package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
  167. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
  168. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
  169. package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
  170. package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
  171. package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
  172. package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
  173. package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
  174. package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
  175. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
  176. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
  177. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
  178. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
  179. package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
  180. package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
  181. package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
  182. package/package.json +151 -0
  183. package/src/Logger.ts +417 -0
  184. package/src/ScopedLogger.ts +94 -0
  185. package/src/config.ts +25 -0
  186. package/src/destinations/Batcher.ts +805 -0
  187. package/src/destinations/ConsoleDestination.ts +60 -0
  188. package/src/destinations/FileDestination.ts +473 -0
  189. package/src/destinations/NativeConsoleDestination.ts +220 -0
  190. package/src/destinations/types.ts +19 -0
  191. package/src/formatters/DefaultFormatter.ts +150 -0
  192. package/src/formatters/JsonLinesFormatter.ts +195 -0
  193. package/src/formatters/timestamp.ts +12 -0
  194. package/src/formatters/types.ts +29 -0
  195. package/src/index.tsx +106 -0
  196. package/src/integrations/appState.ts +111 -0
  197. package/src/integrations/errorHandler.ts +223 -0
  198. package/src/integrations/sanitizeError.ts +287 -0
  199. package/src/levels.ts +35 -0
  200. package/src/metadata.ts +52 -0
  201. package/src/privacy.ts +389 -0
  202. package/src/specs/FileSink.nitro.ts +144 -0
  203. package/src/specs/NativeConsoleSink.nitro.ts +21 -0
  204. package/src/types.ts +45 -0
  205. package/src/utf8.ts +39 -0
@@ -0,0 +1,1948 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared call-site analysis for the privacy lint rules.
5
+ *
6
+ * These rules enforce the "public by contract" half of the privacy design:
7
+ * fields the runtime cannot redact — the message, the subsystem, the
8
+ * correlation ID, metadata keys — have to be provably static at build time.
9
+ * The runtime redacts values; lint keeps everything else from becoming a
10
+ * value in the first place.
11
+ *
12
+ * Three rules govern everything below, and they are what make the analysis a
13
+ * control rather than a speed bump:
14
+ *
15
+ * 1. **Receivers resolve permissively.** Failing to recognize a logger
16
+ * silences every rule at that call site, so aliases, import renames,
17
+ * computed access, `this` members, method references and post-declaration
18
+ * assignment are all followed. A receiver that merely *might* be a logger
19
+ * is `'ambiguous'`, not discarded.
20
+ * 2. **Values resolve strictly.** `const` protects the binding, not the
21
+ * object. A binding is a constant only if nothing writes through it AND it
22
+ * never escapes into code we cannot see.
23
+ * 3. **Unprovable is reported, never skipped.** Every position that could
24
+ * carry a public-by-contract field and cannot be resolved yields an
25
+ * `unanalyzable` result. A spread argument, an opaque options object and a
26
+ * computed property that could shadow a checked key are all findings, not
27
+ * reasons to fall silent.
28
+ */
29
+
30
+ const LEVEL_METHODS = new Set([
31
+ 'verbose',
32
+ 'debug',
33
+ 'info',
34
+ 'warning',
35
+ 'error',
36
+ 'todo',
37
+ ]);
38
+
39
+ /**
40
+ * Level helpers plus the generic entry points.
41
+ *
42
+ * `logMessage` is here because it is public, exported, and the thing `log`
43
+ * delegates to — `log(message, options)` is a one-line passthrough. Leaving it
44
+ * out made `Log.logMessage(`MRN ${x}`, ...)` lint clean while the identical
45
+ * `Log.log(...)` errored, which is not a defence-in-depth gap: message,
46
+ * correlation and subsystem have no runtime redaction anywhere, so these rules
47
+ * are the only thing standing between them and a log file.
48
+ *
49
+ * Anything emitting added to `Logger` must land in this set. That is not left
50
+ * to memory — see the exhaustiveness test in `__tests__/eslintPlugin.test.js`,
51
+ * which fails on a prototype method it cannot classify.
52
+ */
53
+ const LOG_METHODS = new Set([...LEVEL_METHODS, 'log', 'logMessage']);
54
+
55
+ /** Level configuration, which only the Logger singleton exposes. */
56
+ const CONFIG_METHODS = new Set(['subsystem', 'resetSubsystem']);
57
+
58
+ /**
59
+ * Methods whose second argument is a `LogOptions` object rather than metadata.
60
+ *
61
+ * `Log.log` is only this shape when its receiver resolves to the Logger — a
62
+ * ScopedLogger's `log` takes `(message, level, metadata)`. `logMessage` has no
63
+ * such overload, so it is always the options shape.
64
+ */
65
+ const OPTIONS_METHODS = new Set(['log', 'logMessage']);
66
+
67
+ /**
68
+ * Fields on a `LogOptions` object that carry caller metadata.
69
+ *
70
+ * Both reach `redactMetadata` and both are equally public — `scopeMetadata` is
71
+ * what `ScopedLogger` threads through, and nothing stops an application from
72
+ * passing it directly. A rule that read only `metadata` left the other half of
73
+ * the same pipeline unchecked.
74
+ */
75
+ const METADATA_OPTION_FIELDS = ['metadata', 'scopeMetadata'];
76
+
77
+ /** Every method whose arguments are public by contract. */
78
+ const API_METHODS = new Set([...LOG_METHODS, ...CONFIG_METHODS, 'scoped']);
79
+
80
+ /**
81
+ * Every method the package's own Logger and ScopedLogger define.
82
+ *
83
+ * Kept as a closed list, not "any method on a trusted receiver", because
84
+ * `Object.prototype` lends every object methods the package never wrote:
85
+ * `Log.__defineGetter__('newCorrelationId', () => () => patient.mrn)`
86
+ * replaces the generator through a call that mutates nothing the package
87
+ * knows about. A call to anything absent from this list has to count as
88
+ * tampering. Mirrors src/Logger.ts and src/ScopedLogger.ts.
89
+ */
90
+ const LOGGER_OWN_METHODS = new Set([
91
+ ...API_METHODS,
92
+ 'addDestination',
93
+ 'consoleLogging',
94
+ 'flush',
95
+ 'logMessage',
96
+ 'metadataKeyCatalog',
97
+ 'minimumLevel',
98
+ 'newCorrelationId',
99
+ 'privacyDefault',
100
+ 'redactAllMetadata',
101
+ 'removeDestination',
102
+ // TypeScript `private` is erased, so these are ordinary prototype methods at
103
+ // runtime. They belong here for the same reason as the rest: they are the
104
+ // package's own code, so a call to one is not tampering.
105
+ 'noteFailure',
106
+ 'privacySettings',
107
+ ]);
108
+
109
+ const DEFAULT_LOGGER_NAMES = ['Log', 'log', 'logger'];
110
+
111
+ function configuredLoggerNames(context) {
112
+ const configured = context.options?.[0]?.loggerNames;
113
+ return Array.isArray(configured) && configured.length > 0
114
+ ? configured
115
+ : DEFAULT_LOGGER_NAMES;
116
+ }
117
+
118
+ function loggerNames(context) {
119
+ return new Set(configuredLoggerNames(context));
120
+ }
121
+
122
+ /**
123
+ * The one name that means the Logger singleton itself, rather than "some
124
+ * logger-shaped thing".
125
+ *
126
+ * `Log` is the documented export; `log` and `logger` are what people call
127
+ * whatever they have to hand, very often a ScopedLogger. An unresolved
128
+ * `Log.log(…)` can be assumed to have the Logger's argument order; an
129
+ * unresolved `logger.log(…)` cannot.
130
+ *
131
+ * Configurable on its own rather than by `loggerNames` array order, so that
132
+ * reordering that list cannot quietly change which name carries the meaning.
133
+ * This decides argument SHAPE only — trusting a value still needs
134
+ * {@link isTrustedLogger}, which requires a verified import.
135
+ */
136
+ function canonicalLoggerName(context) {
137
+ return (
138
+ context.options?.[0]?.singletonName ?? configuredLoggerNames(context)[0]
139
+ );
140
+ }
141
+
142
+ /** Modules an import of the Logger may legitimately come from. */
143
+ const DEFAULT_LOGGER_MODULES = ['react-native-nitro-logger'];
144
+
145
+ function loggerModules(context) {
146
+ const configured = context.options?.[0]?.loggerModules;
147
+ return new Set(
148
+ Array.isArray(configured) && configured.length > 0
149
+ ? configured
150
+ : DEFAULT_LOGGER_MODULES
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Was this binding imported from a module that actually ships the Logger?
156
+ *
157
+ * A name is not provenance. `import { Log } from './phi-helpers'` binds the
158
+ * documented name to something we know nothing about, and treating it as the
159
+ * singleton would let its `newCorrelationId()` mint approved IDs out of MRNs.
160
+ */
161
+ function isLoggerImport(context, def) {
162
+ if (!def || def.type !== 'ImportBinding') return false;
163
+ const imported = def.node.imported?.name ?? def.node.local?.name;
164
+ if (!loggerNames(context).has(imported)) return false;
165
+ const source = def.parent?.source?.value;
166
+ return typeof source === 'string' && loggerModules(context).has(source);
167
+ }
168
+
169
+ /* -------------------------------------------------------------------------
170
+ * Syntax normalization
171
+ * ---------------------------------------------------------------------- */
172
+
173
+ /**
174
+ * Strip the wrappers that change an expression's spelling but not its value:
175
+ * TypeScript assertions, non-null assertions, optional-chain envelopes and
176
+ * the comma operator. Each is a one-token bypass of a naive AST match —
177
+ * `(0, Log.info)(msg)` and `(Log as any)!.info(msg)` must not read
178
+ * differently from `Log.info(msg)`.
179
+ */
180
+ function unwrap(node) {
181
+ let current = node ?? null;
182
+ for (;;) {
183
+ if (!current) return null;
184
+ switch (current.type) {
185
+ case 'TSAsExpression':
186
+ case 'TSSatisfiesExpression':
187
+ case 'TSNonNullExpression':
188
+ case 'TSTypeAssertion':
189
+ case 'TSInstantiationExpression':
190
+ case 'ChainExpression':
191
+ current = current.expression;
192
+ break;
193
+ case 'SequenceExpression':
194
+ current = current.expressions[current.expressions.length - 1];
195
+ break;
196
+ default:
197
+ return current;
198
+ }
199
+ }
200
+ }
201
+
202
+ /** The key of an object-literal property, or null when computed. */
203
+ function propertyKeyName(property) {
204
+ if (property.computed) return null;
205
+ const key = property.key;
206
+ if (key.type === 'Identifier') return key.name;
207
+ if (key.type === 'Literal') return String(key.value);
208
+ return null;
209
+ }
210
+
211
+ /* -------------------------------------------------------------------------
212
+ * Binding resolution
213
+ * ---------------------------------------------------------------------- */
214
+
215
+ function resolveVariable(context, identifier) {
216
+ let scope = context.sourceCode.getScope(identifier);
217
+ while (scope) {
218
+ const found = scope.variables.find((v) => v.name === identifier.name);
219
+ if (found) return found;
220
+ scope = scope.upper;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function singleDef(variable) {
226
+ return variable && variable.defs.length === 1 ? variable.defs[0] : null;
227
+ }
228
+
229
+ /**
230
+ * The one value a binding is ever given, whether at its declaration or by a
231
+ * single later assignment.
232
+ *
233
+ * Receivers only. `let sink; sink = Log; sink.info(msg)` is a logger, and
234
+ * `let s = Log.scoped(id)` is a scope — refusing to follow either would mean
235
+ * one keyword disables every rule.
236
+ */
237
+ function bindingInit(variable) {
238
+ const def = singleDef(variable);
239
+ if (!def || def.type !== 'Variable') return null;
240
+
241
+ const pattern = def.node.id;
242
+ if (def.node.init) {
243
+ const init = unwrap(def.node.init);
244
+ if (pattern.type === 'Identifier') return init;
245
+ // `const [a] = [Log]` — follow the element this binding actually took.
246
+ if (
247
+ pattern.type === 'ArrayPattern' &&
248
+ init &&
249
+ init.type === 'ArrayExpression'
250
+ ) {
251
+ const index = pattern.elements.indexOf(def.name);
252
+ const element = index < 0 ? null : init.elements[index];
253
+ return element ? unwrap(element) : null;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // Declared empty, then assigned exactly once.
259
+ const writes = variable.references.filter(
260
+ (reference) => reference.isWrite() && reference.writeExpr
261
+ );
262
+ return writes.length === 1 ? unwrap(writes[0].writeExpr) : null;
263
+ }
264
+
265
+ /**
266
+ * The initializer of a single-definition `const`.
267
+ *
268
+ * Values only. Unlike {@link bindingInit} this refuses `let`, because an
269
+ * approved constant that can be reassigned is not a constant.
270
+ */
271
+ function immutableInit(variable) {
272
+ const def = singleDef(variable);
273
+ if (!def || def.type !== 'Variable' || def.parent?.kind !== 'const') {
274
+ return null;
275
+ }
276
+ return def.node.init ? unwrap(def.node.init) : null;
277
+ }
278
+
279
+ /**
280
+ * Is this node in a position that gets written to?
281
+ *
282
+ * Simple assignment is the obvious case, but a member expression can also be
283
+ * a destructuring target (`({ x: M.up } = leak)`, `[M.up] = [leak]`) or a
284
+ * loop binding (`for (M.up of xs)`), and those reach through arbitrarily
285
+ * nested patterns. So climb while the node stays in target position and
286
+ * decide at whatever construct the pattern terminates in.
287
+ */
288
+ function isWriteTarget(node) {
289
+ let current = node;
290
+ let parent = current.parent;
291
+ while (parent) {
292
+ switch (parent.type) {
293
+ case 'AssignmentExpression':
294
+ return parent.left === current;
295
+ case 'ForOfStatement':
296
+ case 'ForInStatement':
297
+ return parent.left === current;
298
+ case 'UpdateExpression':
299
+ return parent.argument === current;
300
+ case 'UnaryExpression':
301
+ return parent.operator === 'delete' && parent.argument === current;
302
+ // Still inside a pattern — keep climbing toward whatever assigns it.
303
+ case 'AssignmentPattern':
304
+ if (parent.left !== current) return false;
305
+ break;
306
+ case 'Property':
307
+ if (parent.value !== current) return false;
308
+ break;
309
+ case 'ObjectPattern':
310
+ case 'ArrayPattern':
311
+ case 'RestElement':
312
+ break;
313
+ default:
314
+ return false;
315
+ }
316
+ current = parent;
317
+ parent = current.parent;
318
+ }
319
+ return false;
320
+ }
321
+
322
+ /**
323
+ * `Object.*` and `Reflect.*` statics that hand the object back unchanged
324
+ * *without running any of its code*.
325
+ *
326
+ * The second half of that is the load-bearing part. `Object.values`,
327
+ * `Object.entries`, `Reflect.get` and `JSON.stringify` all look like reads and
328
+ * are not: they invoke getters (and `toJSON`), so
329
+ * `const M = { up: 'safe', get x() { this.up = patientName } }; Object.values(M)`
330
+ * rewrites `M.up` before it is logged. Only operations that touch keys and
331
+ * descriptors rather than values are listed here.
332
+ *
333
+ * Everything else on those namespaces — `assign`, `defineProperty`, `set`,
334
+ * `deleteProperty`, `setPrototypeOf` — rewrites the object outright.
335
+ */
336
+ const NON_MUTATING_STATICS = new Set([
337
+ 'keys',
338
+ 'freeze',
339
+ 'isFrozen',
340
+ 'seal',
341
+ 'isSealed',
342
+ 'getOwnPropertyNames',
343
+ 'getOwnPropertyDescriptor',
344
+ 'getOwnPropertyDescriptors',
345
+ 'getPrototypeOf',
346
+ 'has',
347
+ 'ownKeys',
348
+ ]);
349
+ const BUILTIN_NAMESPACES = new Set(['Object', 'Reflect', 'JSON']);
350
+ const GLOBAL_OBJECT_NAMES = new Set(['globalThis', 'global', 'window', 'self']);
351
+
352
+ /**
353
+ * Builtin namespaces this file tampers with, in any of the ways that leave
354
+ * the binding itself looking pristine.
355
+ *
356
+ * `const JSON = {…}` is a shadow and resolves as a definition, so scope
357
+ * resolution catches it. These do not:
358
+ *
359
+ * - `Object.keys = (o) => { o.up = patientName; return [] }` — the namespace
360
+ * is the real one; a single method on it is not.
361
+ * - `globalThis.JSON = fake` and `globalThis['JSON'] = fake` — replacement
362
+ * through the global object.
363
+ * - `globalThis[name] = fake` — an unreadable key, so every namespace has to
364
+ * be assumed replaced.
365
+ * - `Object.defineProperty(Object, 'keys', …)`, `Object.assign(Object, …)`,
366
+ * `tamper(Object)` — handing the namespace to code that can rewrite it.
367
+ *
368
+ * One walk per file, cached.
369
+ */
370
+ const poisonedGlobalsCache = new WeakMap();
371
+ const ALL_NAMESPACES_POISONED = Symbol('all');
372
+
373
+ /**
374
+ * Walk `a.b.c` down to its base identifier, collecting the property path.
375
+ * Unreadable (computed, non-literal) steps come back as null so the caller
376
+ * can decide how much to assume.
377
+ */
378
+ function memberChain(node) {
379
+ const path = [];
380
+ let current = unwrap(node);
381
+ while (current && current.type === 'MemberExpression') {
382
+ if (!current.computed && current.property.type === 'Identifier') {
383
+ path.unshift(current.property.name);
384
+ } else {
385
+ const literal = unwrap(current.property);
386
+ path.unshift(
387
+ literal && literal.type === 'Literal' ? String(literal.value) : null
388
+ );
389
+ }
390
+ current = unwrap(current.object);
391
+ }
392
+ return { root: current, path };
393
+ }
394
+
395
+ /**
396
+ * Does this expression name a builtin namespace, directly or through a const
397
+ * alias?
398
+ *
399
+ * The binding is resolved BEFORE the spelling is trusted. A local
400
+ * `function f() { const Object = {}; Object.keys = fake }` writes to its own
401
+ * shadow, and reading that as tampering with the global would disable
402
+ * `Object.keys` for the whole file — a false positive in exactly the code
403
+ * most likely to be doing something ordinary.
404
+ */
405
+ function builtinNamespaceOf(context, node, seen = new Set()) {
406
+ const current = unwrap(node);
407
+ if (!current) return null;
408
+
409
+ // `globalThis.Object`, and `const O = globalThis.Object` behind it.
410
+ if (current.type === 'MemberExpression') {
411
+ const { root, path } = memberChain(current);
412
+ if (path.length !== 1 || path[0] === null) return null;
413
+ if (!BUILTIN_NAMESPACES.has(path[0])) return null;
414
+ return isGlobalObjectRef(context, root) ? path[0] : null;
415
+ }
416
+
417
+ if (current.type !== 'Identifier') return null;
418
+ const variable = resolveVariable(context, current);
419
+ if (!variable || variable.defs.length === 0) {
420
+ return BUILTIN_NAMESPACES.has(current.name) ? current.name : null;
421
+ }
422
+ if (seen.has(variable)) return null;
423
+ seen.add(variable);
424
+ return builtinNamespaceOf(context, immutableInit(variable), seen);
425
+ }
426
+
427
+ /** Likewise for the global object: `globalThis`, or a const alias of it. */
428
+ function isGlobalObjectRef(context, node, seen = new Set()) {
429
+ const current = unwrap(node);
430
+ if (!current || current.type !== 'Identifier') return false;
431
+ const variable = resolveVariable(context, current);
432
+ if (!variable || variable.defs.length === 0) {
433
+ return GLOBAL_OBJECT_NAMES.has(current.name);
434
+ }
435
+ if (seen.has(variable)) return false;
436
+ seen.add(variable);
437
+ return isGlobalObjectRef(context, immutableInit(variable), seen);
438
+ }
439
+
440
+ function poisonedGlobals(context) {
441
+ const cached = poisonedGlobalsCache.get(context);
442
+ if (cached) return cached;
443
+
444
+ const poisoned = new Set();
445
+ // A namespace handed to a function can be wrapped on the way:
446
+ // `tamper(...[Object])` and `tamper({ ns: Object })` pass the real thing.
447
+ const poisonNamespaceOf = (node, seen = new Set()) => {
448
+ const current = unwrap(node);
449
+ if (!current || seen.has(current)) return;
450
+ seen.add(current);
451
+ // A cycle or a pathological nesting depth must not end the search
452
+ // quietly: an unfinished search is exactly the case where a namespace
453
+ // could be in there somewhere.
454
+ if (seen.size > 64) {
455
+ poisoned.add(ALL_NAMESPACES_POISONED);
456
+ return;
457
+ }
458
+
459
+ const name = builtinNamespaceOf(context, current);
460
+ if (name !== null) {
461
+ poisoned.add(name);
462
+ return;
463
+ }
464
+ switch (current.type) {
465
+ case 'SpreadElement':
466
+ poisonNamespaceOf(current.argument, seen);
467
+ return;
468
+ case 'ArrayExpression':
469
+ for (const element of current.elements) {
470
+ poisonNamespaceOf(element, seen);
471
+ }
472
+ return;
473
+ case 'ObjectExpression':
474
+ for (const property of current.properties) {
475
+ poisonNamespaceOf(
476
+ property.type === 'SpreadElement'
477
+ ? property.argument
478
+ : property.value,
479
+ seen
480
+ );
481
+ }
482
+ return;
483
+ case 'ConditionalExpression':
484
+ poisonNamespaceOf(current.consequent, seen);
485
+ poisonNamespaceOf(current.alternate, seen);
486
+ return;
487
+ case 'LogicalExpression':
488
+ poisonNamespaceOf(current.left, seen);
489
+ poisonNamespaceOf(current.right, seen);
490
+ return;
491
+ case 'Identifier': {
492
+ // `const namespaces = { object: Object }; tamper(namespaces)` — the
493
+ // wrapper is a name, and the namespace is inside what it holds.
494
+ const variable = resolveVariable(context, current);
495
+ if (!variable || seen.has(variable)) return;
496
+ seen.add(variable);
497
+ poisonNamespaceOf(immutableInit(variable), seen);
498
+
499
+ // The wrapper may have been filled in after it was declared:
500
+ // `const bag = {}; bag.namespace = Object; tamper(bag)`. Its
501
+ // declaration says nothing, so the writes have to be read too —
502
+ // at any depth (`bag.inner.namespace = Object`) and through any
503
+ // alias (`const b = bag; b.namespace = Object`).
504
+ for (const reference of variable.references) {
505
+ const parent = reference.identifier.parent;
506
+ if (!parent) continue;
507
+
508
+ if (
509
+ parent.type === 'VariableDeclarator' &&
510
+ parent.init === reference.identifier &&
511
+ parent.id.type === 'Identifier'
512
+ ) {
513
+ poisonNamespaceOf(parent.id, seen);
514
+ continue;
515
+ }
516
+
517
+ if (
518
+ parent.type !== 'MemberExpression' ||
519
+ parent.object !== reference.identifier
520
+ ) {
521
+ continue;
522
+ }
523
+ let member = parent;
524
+ for (;;) {
525
+ const owner = member.parent;
526
+ if (!owner) break;
527
+ if (
528
+ owner.type === 'AssignmentExpression' &&
529
+ owner.left === member
530
+ ) {
531
+ poisonNamespaceOf(owner.right, seen);
532
+ break;
533
+ }
534
+ if (owner.type === 'MemberExpression' && owner.object === member) {
535
+ member = owner;
536
+ continue;
537
+ }
538
+ break;
539
+ }
540
+ }
541
+ return;
542
+ }
543
+ default:
544
+ return;
545
+ }
546
+ };
547
+ const poisonWriteTarget = (target) => {
548
+ const { root, path } = memberChain(target);
549
+ if (!root || root.type !== 'Identifier' || path.length === 0) return;
550
+
551
+ // `Object.keys = …`, and `const O = Object; O.keys = …` — a method on the
552
+ // real namespace, however the namespace was spelled.
553
+ const namespace = builtinNamespaceOf(context, root);
554
+ if (namespace !== null) {
555
+ poisoned.add(namespace);
556
+ return;
557
+ }
558
+
559
+ // `globalThis.JSON = …`, `globalThis['JSON'].parse = …`,
560
+ // `const g = globalThis; g.JSON = …` — replacement reached through the
561
+ // global object, at any depth.
562
+ if (!isGlobalObjectRef(context, root)) return;
563
+ const key = path[0];
564
+ if (key === null) poisoned.add(ALL_NAMESPACES_POISONED);
565
+ else if (BUILTIN_NAMESPACES.has(key)) poisoned.add(key);
566
+ };
567
+
568
+ // A write target can be a whole destructuring pattern:
569
+ // `({ keys: Object.keys } = { keys: fake })` replaces the builtin without
570
+ // any assignment whose left side is a member expression.
571
+ const poisonPattern = (node) => {
572
+ if (!node) return;
573
+ switch (node.type) {
574
+ case 'ObjectPattern':
575
+ for (const property of node.properties) {
576
+ poisonPattern(
577
+ property.type === 'RestElement' ? property.argument : property.value
578
+ );
579
+ }
580
+ return;
581
+ case 'ArrayPattern':
582
+ for (const element of node.elements) poisonPattern(element);
583
+ return;
584
+ case 'AssignmentPattern':
585
+ poisonPattern(node.left);
586
+ return;
587
+ case 'RestElement':
588
+ poisonPattern(node.argument);
589
+ return;
590
+ default:
591
+ poisonWriteTarget(node);
592
+ }
593
+ };
594
+
595
+ const seenNodes = new Set();
596
+ const visit = (node) => {
597
+ if (!node || typeof node.type !== 'string' || seenNodes.has(node)) return;
598
+ seenNodes.add(node);
599
+
600
+ switch (node.type) {
601
+ case 'AssignmentExpression':
602
+ poisonPattern(node.left);
603
+ break;
604
+ case 'ForOfStatement':
605
+ case 'ForInStatement':
606
+ // `for (Object.keys of [fake]) {}`. A `VariableDeclaration` here
607
+ // declares a fresh binding and falls through harmlessly.
608
+ poisonPattern(node.left);
609
+ break;
610
+ case 'UpdateExpression':
611
+ poisonWriteTarget(node.argument);
612
+ break;
613
+ case 'UnaryExpression':
614
+ if (node.operator === 'delete') poisonWriteTarget(node.argument);
615
+ break;
616
+ case 'CallExpression':
617
+ case 'NewExpression':
618
+ // A namespace handed to a function is a namespace that function can
619
+ // rewrite, whatever the function is.
620
+ for (const argument of node.arguments) poisonNamespaceOf(argument);
621
+ break;
622
+ default:
623
+ break;
624
+ }
625
+
626
+ for (const key of Object.keys(node)) {
627
+ if (key === 'parent') continue;
628
+ const value = node[key];
629
+ if (Array.isArray(value)) value.forEach(visit);
630
+ else if (value && typeof value.type === 'string') visit(value);
631
+ }
632
+ };
633
+ visit((context.sourceCode ?? context.getSourceCode()).ast);
634
+
635
+ poisonedGlobalsCache.set(context, poisoned);
636
+ return poisoned;
637
+ }
638
+
639
+ /** Is this identifier an untouched global with the given name? */
640
+ function isGlobalNamed(context, node, names) {
641
+ if (!node || node.type !== 'Identifier' || !names.has(node.name))
642
+ return false;
643
+ const poisoned = poisonedGlobals(context);
644
+ if (poisoned.has(ALL_NAMESPACES_POISONED) || poisoned.has(node.name)) {
645
+ return false;
646
+ }
647
+ const variable = resolveVariable(context, node);
648
+ // Absent, or present as a declared global with no definition of its own.
649
+ return !variable || variable.defs.length === 0;
650
+ }
651
+
652
+ /**
653
+ * A read-only builtin static, like `Object.keys(M)`.
654
+ *
655
+ * The namespace has to be the real global: `const JSON = { stringify: (o) =>
656
+ * { o.up = leak; return ''; } }` would otherwise launder a mutation through
657
+ * the allowlist.
658
+ */
659
+ function isNonMutatingStatic(context, node) {
660
+ const callee = unwrap(node.callee);
661
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) {
662
+ return false;
663
+ }
664
+ return (
665
+ isGlobalNamed(context, unwrap(callee.object), BUILTIN_NAMESPACES) &&
666
+ callee.property.type === 'Identifier' &&
667
+ NON_MUTATING_STATICS.has(callee.property.name)
668
+ );
669
+ }
670
+
671
+ /**
672
+ * Can this call mutate an object handed to it?
673
+ *
674
+ * A real logger call cannot — passing a metadata object to `Log.info` is the
675
+ * entire point of having one — and neither can the read-only builtin statics.
676
+ * Anything else is a function we cannot see inside.
677
+ *
678
+ * "A real logger call" means one whose receiver is PROVABLY the shipped
679
+ * Logger, not one that merely classifies. Classification accepts an
680
+ * unresolved `logger.info(M)` so its arguments still get checked, but
681
+ * exempting that same call from mutation analysis would trust it:
682
+ * `function f(logger) { const M = { up: 'safe' }; logger.info(M);
683
+ * Log.info(M.up) }` hands `M` to a function we know nothing about, which is
684
+ * free to rewrite `M.up` into PHI before the second call reads it.
685
+ */
686
+ function callCanMutateArguments(context, node) {
687
+ if (isNonMutatingStatic(context, node)) return false;
688
+ return !isTrustedLoggerCall(context, node);
689
+ }
690
+
691
+ /** An API method invoked on a receiver proven to be the shipped Logger. */
692
+ function isTrustedApiMethod(context, member) {
693
+ const method = staticPropertyName(context, member);
694
+ return (
695
+ method !== null &&
696
+ API_METHODS.has(method) &&
697
+ isTrustedLogger(context, member.object)
698
+ );
699
+ }
700
+
701
+ function isTrustedLoggerCall(context, node) {
702
+ const callee = unwrap(node.callee);
703
+ if (!callee || callee.type !== 'MemberExpression') return false;
704
+
705
+ const method = staticPropertyName(context, callee);
706
+ if (method === null) return false;
707
+
708
+ // `Log.info.call(thisArg, 'm', M)` — the real method is one level in, but
709
+ // the method alone is not enough. `.call` runs that real body against a
710
+ // receiver of the caller's choosing, and the logger reaches its sinks
711
+ // through `this`, so a forged receiver
712
+ // (`Log.info.call({ logMessage(_m, o) { o.up = patientName } }, 'm', M)`)
713
+ // mutates M from inside a call that looks entirely legitimate. The `this`
714
+ // has to be the real Logger too.
715
+ if (method === 'call' || method === 'apply') {
716
+ const inner = unwrap(callee.object);
717
+ if (!inner || inner.type !== 'MemberExpression') return false;
718
+ if (!isTrustedApiMethod(context, inner)) return false;
719
+ const thisArg = node.arguments[0];
720
+ return (
721
+ !!thisArg &&
722
+ thisArg.type !== 'SpreadElement' &&
723
+ isTrustedLogger(context, thisArg)
724
+ );
725
+ }
726
+
727
+ return isTrustedApiMethod(context, callee);
728
+ }
729
+
730
+ /**
731
+ * Does the binding leave this file's sight — written through, or handed to
732
+ * code that could write through it?
733
+ *
734
+ * `const` freezes the reference and nothing else. A message table patched
735
+ * after definition (`MESSAGES.startup = patientName`) reads as a table of
736
+ * literals at its declaration and leaks at the call site, and a table handed
737
+ * to `mutate(MESSAGES)` can be rewritten by a function we never see.
738
+ *
739
+ * Being read as a property, aliased to another const, or passed to one of the
740
+ * logger's own methods is not an escape — the last one matters, because
741
+ * handing a metadata object to `Log.info` is the entire point of having one.
742
+ */
743
+ /**
744
+ * Guards the cycle `isMutable` → `callCanMutateArguments` → `describeCall` →
745
+ * `classifyReceiver` → `staticPropertyName` → `staticStringValue` →
746
+ * `isMutable`. Re-entering for the same binding means the question is already
747
+ * being answered further up the stack; assuming immutable there terminates
748
+ * without changing the outcome, because the outer frame still applies every
749
+ * check to that same binding.
750
+ */
751
+ const mutabilityInProgress = new Set();
752
+
753
+ /**
754
+ * Answers are cached per rule run. `isMutable` walks every reference of a
755
+ * binding, and resolution asks about the same binding once per call site, so
756
+ * an uncached message table referenced by R log calls costs O(R²). The cache
757
+ * is keyed on `context` as well as the variable because `loggerNames` is
758
+ * configurable per rule, and that changes what counts as a logger call.
759
+ */
760
+ const mutabilityCache = new WeakMap();
761
+
762
+ function isMutable(context, variable) {
763
+ let perContext = mutabilityCache.get(context);
764
+ if (!perContext) {
765
+ perContext = new WeakMap();
766
+ mutabilityCache.set(context, perContext);
767
+ }
768
+ const cached = perContext.get(variable);
769
+ if (cached !== undefined) return cached;
770
+
771
+ if (mutabilityInProgress.has(variable)) return false;
772
+ mutabilityInProgress.add(variable);
773
+ let result;
774
+ try {
775
+ result = computeIsMutable(context, variable);
776
+ } finally {
777
+ mutabilityInProgress.delete(variable);
778
+ }
779
+ perContext.set(variable, result);
780
+ return result;
781
+ }
782
+
783
+ /** Every identifier a binding pattern introduces, however deeply nested. */
784
+ function collectPatternIdentifiers(node, out) {
785
+ if (!node) return;
786
+ switch (node.type) {
787
+ case 'Identifier':
788
+ out.push(node);
789
+ return;
790
+ case 'ObjectPattern':
791
+ for (const property of node.properties) {
792
+ collectPatternIdentifiers(
793
+ property.type === 'RestElement' ? property.argument : property.value,
794
+ out
795
+ );
796
+ }
797
+ return;
798
+ case 'ArrayPattern':
799
+ for (const element of node.elements) {
800
+ collectPatternIdentifiers(element, out);
801
+ }
802
+ return;
803
+ case 'AssignmentPattern':
804
+ collectPatternIdentifiers(node.left, out);
805
+ return;
806
+ case 'RestElement':
807
+ collectPatternIdentifiers(node.argument, out);
808
+ return;
809
+ default:
810
+ // A member expression as a destructuring target is a write, which
811
+ // `isWriteTarget` already reports.
812
+ return;
813
+ }
814
+ }
815
+
816
+ function bindingIdentifiersMutable(context, node) {
817
+ const identifiers = [];
818
+ collectPatternIdentifiers(node, identifiers);
819
+ return identifiers.some((identifier) => {
820
+ const variable = resolveVariable(context, identifier);
821
+ return !variable || isMutable(context, variable);
822
+ });
823
+ }
824
+
825
+ /** A value that is copied on read, so it carries no object identity. */
826
+ function isPrimitiveLiteral(node) {
827
+ const value = unwrap(node);
828
+ if (!value) return false;
829
+ if (value.type === 'Literal') return !value.regex;
830
+ return value.type === 'TemplateLiteral' && value.expressions.length === 0;
831
+ }
832
+
833
+ /**
834
+ * Destructuring reads the source, but a binding it extracts can still ALIAS
835
+ * part of it: `const { inner } = md; inner.k = patientName` rewrites `md`.
836
+ * So each extracted binding has to clear the same bar the source does.
837
+ *
838
+ * A binding that took a primitive is exempt, and that exemption is what keeps
839
+ * the rule usable. `const { up } = M` copies the string, so nothing done to
840
+ * `up` afterwards can reach `M` — without this, destructuring one scalar and
841
+ * passing it anywhere would make the whole table unresolvable, which is
842
+ * ordinary code rather than a leak.
843
+ *
844
+ * Anything not proven primitive stays conservative: rest elements, computed
845
+ * keys, defaults, nested patterns, and every key when the source literal
846
+ * itself could not be resolved.
847
+ */
848
+ function patternBindingsMutable(context, pattern, sourceLiteral) {
849
+ for (const property of pattern.properties) {
850
+ if (property.type === 'RestElement') {
851
+ if (bindingIdentifiersMutable(context, property.argument)) return true;
852
+ continue;
853
+ }
854
+ const key = property.computed ? null : propertyKeyName(property);
855
+ const source =
856
+ key !== null && sourceLiteral
857
+ ? objectProperty(context, sourceLiteral, key)
858
+ : null;
859
+ if (
860
+ source &&
861
+ property.value.type === 'Identifier' &&
862
+ isPrimitiveLiteral(source.value)
863
+ ) {
864
+ continue;
865
+ }
866
+ if (bindingIdentifiersMutable(context, property.value)) return true;
867
+ }
868
+ return false;
869
+ }
870
+
871
+ function computeIsMutable(context, variable) {
872
+ for (const reference of variable.references) {
873
+ if (reference.isWrite()) {
874
+ // A write that is not the declaration's own initializer is a rebind.
875
+ if (reference.init !== true) return true;
876
+ // The declaration's own binding occurrence says nothing about mutation,
877
+ // and it must not reach the escape analysis below: a destructured
878
+ // binding sits inside a `Property`, which that analysis reads as the
879
+ // very different `{ key: M }`.
880
+ continue;
881
+ }
882
+
883
+ const identifier = reference.identifier;
884
+ const parent = identifier.parent;
885
+ if (!parent) continue;
886
+
887
+ if (parent.type === 'MemberExpression' && parent.object === identifier) {
888
+ // Climb the WHOLE chain before deciding. A write or a call anywhere
889
+ // along it reaches the root: `M.a.b = patientName` rewrites part of M,
890
+ // and `Log.constructor.prototype.newCorrelationId = …` replaces the
891
+ // generator while the reference's immediate parent is only
892
+ // `Log.constructor`.
893
+ let member = parent;
894
+ for (;;) {
895
+ if (isWriteTarget(member)) return true;
896
+
897
+ // `M.__defineGetter__('t', function () { this.up = patientName })`
898
+ // installs an accessor without anything that reads as a write, and
899
+ // `Object.prototype` hands that method to every object. So a call
900
+ // THROUGH the binding is a possible mutation.
901
+ //
902
+ // A proven Logger calling one of its OWN methods is the exception,
903
+ // and has to be: `Log.info('m', md)`, `Log.newCorrelationId()` and
904
+ // `scope.info('m', md)` are all calls through a binding, and treating
905
+ // them as mutations would make every Logger binding mutable, which
906
+ // would un-trust the logger and cascade into every rule.
907
+ //
908
+ // Both halves are load-bearing. Dropping the receiver check would
909
+ // exempt `attacker.info(M)`; dropping the method check would exempt
910
+ // `Log.__defineGetter__('newCorrelationId', …)`, which replaces the
911
+ // generator using a method the package never wrote.
912
+ const invocation = member.parent;
913
+ const invoked =
914
+ invocation &&
915
+ ((invocation.type === 'CallExpression' &&
916
+ unwrap(invocation.callee) === member) ||
917
+ (invocation.type === 'NewExpression' &&
918
+ unwrap(invocation.callee) === member) ||
919
+ // `M.mutate`x`` calls M.mutate with `this === M` too.
920
+ (invocation.type === 'TaggedTemplateExpression' &&
921
+ unwrap(invocation.tag) === member));
922
+ if (invoked) {
923
+ // `Log.info.call(Log, …)` invokes at the second level, so unwrap
924
+ // the forwarding method to the real one before judging it.
925
+ let target = member;
926
+ let method = staticPropertyName(context, member);
927
+ if (method === 'call' || method === 'apply' || method === 'bind') {
928
+ const inner = unwrap(member.object);
929
+ if (inner && inner.type === 'MemberExpression') {
930
+ target = inner;
931
+ method = staticPropertyName(context, inner);
932
+ }
933
+ }
934
+ const ownApiCall =
935
+ method !== null &&
936
+ LOGGER_OWN_METHODS.has(method) &&
937
+ // The method has to hang off the binding itself. Otherwise
938
+ // `Log.constructor.prototype.info()` would ride the exemption on
939
+ // a name it only borrowed.
940
+ unwrap(target.object) === identifier &&
941
+ isTrustedLogger(context, identifier);
942
+ if (!ownApiCall) return true;
943
+ }
944
+
945
+ const next = member.parent;
946
+ if (
947
+ next &&
948
+ next.type === 'MemberExpression' &&
949
+ next.object === member
950
+ ) {
951
+ member = next;
952
+ continue;
953
+ }
954
+ break;
955
+ }
956
+ continue;
957
+ }
958
+
959
+ // `const A = M` — the alias points at the same object, so a write through
960
+ // A is a write through M. Following it beats treating aliasing as an
961
+ // escape, which would reject the ordinary `const md = base;` refactor.
962
+ if (parent.type === 'VariableDeclarator' && parent.init === identifier) {
963
+ if (parent.id.type === 'Identifier') {
964
+ const alias = resolveVariable(context, parent.id);
965
+ if (!alias || isMutable(context, alias)) return true;
966
+ continue;
967
+ }
968
+ // `const { userId } = md` reads M rather than writing it — but each
969
+ // binding it extracts can alias a nested object, so they have to clear
970
+ // the same bar the whole-object alias above does.
971
+ if (parent.id.type === 'ObjectPattern') {
972
+ const literal = unwrapFreeze(context, immutableInit(variable));
973
+ const source =
974
+ literal && literal.type === 'ObjectExpression' ? literal : null;
975
+ if (patternBindingsMutable(context, parent.id, source)) return true;
976
+ continue;
977
+ }
978
+ // An ArrayPattern runs the iteration protocol, which is code.
979
+ return true;
980
+ }
981
+
982
+ // Handed to a function that could write through it.
983
+ if (parent.type === 'CallExpression' || parent.type === 'NewExpression') {
984
+ if (parent.callee === identifier) continue;
985
+ if (
986
+ parent.type === 'CallExpression' &&
987
+ !callCanMutateArguments(context, parent)
988
+ ) {
989
+ continue;
990
+ }
991
+ return true;
992
+ }
993
+
994
+ // Spread into another object, returned, put in an array — out of sight.
995
+ //
996
+ // `await M` belongs here too, and not obviously: awaiting a value calls
997
+ // its `then` with `this` bound to it, so a thenable metadata object
998
+ // rewrites itself at the await. `yield M` hands it to whoever drives the
999
+ // iterator.
1000
+ if (
1001
+ parent.type === 'SpreadElement' ||
1002
+ parent.type === 'ReturnStatement' ||
1003
+ parent.type === 'ArrayExpression' ||
1004
+ parent.type === 'Property' ||
1005
+ parent.type === 'AssignmentExpression' ||
1006
+ parent.type === 'AwaitExpression' ||
1007
+ parent.type === 'YieldExpression'
1008
+ ) {
1009
+ return true;
1010
+ }
1011
+ }
1012
+ return false;
1013
+ }
1014
+
1015
+ /** Is this identifier the untouched global `Object`? */
1016
+ function isGlobalObject(context, node) {
1017
+ if (!node || node.type !== 'Identifier' || node.name !== 'Object') {
1018
+ return false;
1019
+ }
1020
+ const variable = resolveVariable(context, node);
1021
+ // Absent, or present as a declared global with no definition of its own.
1022
+ return !variable || variable.defs.length === 0;
1023
+ }
1024
+
1025
+ /** `Object.freeze({...})` → the object literal, via the real `Object`. */
1026
+ function unwrapFreeze(context, node) {
1027
+ const call = unwrap(node);
1028
+ if (!call || call.type !== 'CallExpression') return call;
1029
+ const callee = unwrap(call.callee);
1030
+ if (
1031
+ callee &&
1032
+ callee.type === 'MemberExpression' &&
1033
+ staticPropertyName(context, callee) === 'freeze' &&
1034
+ isGlobalObject(context, unwrap(callee.object)) &&
1035
+ call.arguments.length === 1
1036
+ ) {
1037
+ return unwrap(call.arguments[0]);
1038
+ }
1039
+ return call;
1040
+ }
1041
+
1042
+ /* -------------------------------------------------------------------------
1043
+ * Value analysis
1044
+ * ---------------------------------------------------------------------- */
1045
+
1046
+ /**
1047
+ * Resolve an expression to an object literal, following `const` aliases and
1048
+ * `Object.freeze`. Returns null when it cannot be proven to be one —
1049
+ * including when the binding is mutable by the definition above.
1050
+ */
1051
+ /**
1052
+ * Can reading this literal run any code?
1053
+ *
1054
+ * A table of constants is only constant if nothing inside it executes. Four
1055
+ * ways a literal smuggles in behaviour, all of which rewrite an approved
1056
+ * value before it is logged:
1057
+ *
1058
+ * - **A getter or setter.** Not a value but a callback, which the runtime
1059
+ * invokes while redacting, and which can rewrite its own siblings on the
1060
+ * way past: `{ up: 'safe', get t() { this.up = patientName } }`.
1061
+ * - **A shorthand method**, the same hazard reached through `toJSON`.
1062
+ * - **A function- or class-valued property.** `property.method` is false for
1063
+ * `{ mutate: function () { this.up = patientName } }`, but `M.mutate()`
1064
+ * rewrites `up` just the same, and nothing about that call reads as a write
1065
+ * to `M`.
1066
+ * - **`__proto__`.** It sets the prototype, so the getters and methods that
1067
+ * can run are ones this literal never mentions:
1068
+ * `{ __proto__: P, up: 'safe' }` inherits everything `P` defines.
1069
+ *
1070
+ * Nested literals are checked too — a getter one level down still runs, and
1071
+ * still reaches the outer object through a closure.
1072
+ */
1073
+ function literalRunsCode(context, object, seen = new Set()) {
1074
+ if (seen.has(object)) return false;
1075
+ seen.add(object);
1076
+ return object.properties.some((property) => {
1077
+ if (property.type === 'SpreadElement') return false;
1078
+ if (property.kind !== 'init' || property.method) return true;
1079
+ if (!property.computed && propertyKeyName(property) === '__proto__') {
1080
+ return true;
1081
+ }
1082
+ return valueRunsCode(context, property.value, seen);
1083
+ });
1084
+ }
1085
+
1086
+ /**
1087
+ * The same question for a single value, at arbitrary depth.
1088
+ *
1089
+ * Split out because the hazard does not care how deeply it is nested or how
1090
+ * many names it is behind: `{ h: [[fn]] }` and
1091
+ * `const mutate = function () {…}; { mutate }` hide exactly what
1092
+ * `{ mutate() {…} }` states outright. Identifiers are followed through their
1093
+ * `const` initializers so an alias is not a way around the check.
1094
+ *
1095
+ * A call is NOT treated as executable. `{ userId: pub(value) }` is the
1096
+ * documented way to write metadata, and the call runs once while the literal
1097
+ * is built — before anything reads it — so it is not a callback the reader
1098
+ * can trigger.
1099
+ */
1100
+ function valueRunsCode(context, node, seen = new Set()) {
1101
+ const value = unwrap(node);
1102
+ if (!value) return false;
1103
+ switch (value.type) {
1104
+ case 'FunctionExpression':
1105
+ case 'ArrowFunctionExpression':
1106
+ case 'ClassExpression':
1107
+ return true;
1108
+ case 'ObjectExpression':
1109
+ return literalRunsCode(context, value, seen);
1110
+ case 'ArrayExpression':
1111
+ return value.elements.some((element) =>
1112
+ valueRunsCode(context, element, seen)
1113
+ );
1114
+ case 'Identifier': {
1115
+ const variable = resolveVariable(context, value);
1116
+ if (!variable || seen.has(variable)) return false;
1117
+ seen.add(variable);
1118
+ return valueRunsCode(context, immutableInit(variable), seen);
1119
+ }
1120
+ default:
1121
+ return false;
1122
+ }
1123
+ }
1124
+
1125
+ function resolveObjectLiteral(context, node, seen = new Set()) {
1126
+ const current = unwrapFreeze(context, node);
1127
+ if (!current) return null;
1128
+ if (current.type === 'ObjectExpression') {
1129
+ return literalRunsCode(context, current) ? null : current;
1130
+ }
1131
+
1132
+ if (current.type === 'Identifier') {
1133
+ const variable = resolveVariable(context, current);
1134
+ if (!variable || seen.has(variable)) return null;
1135
+ seen.add(variable);
1136
+ if (isMutable(context, variable)) return null;
1137
+ return resolveObjectLiteral(context, immutableInit(variable), seen);
1138
+ }
1139
+
1140
+ return null;
1141
+ }
1142
+
1143
+ /**
1144
+ * Look a property up in an object literal.
1145
+ *
1146
+ * A spread or a computed key anywhere poisons the whole lookup: both can
1147
+ * introduce or override the key we are about to approve, and neither is
1148
+ * visible to static analysis. `{ correlation: newCorrelationId(), [k]: id }`
1149
+ * is safe only if `k !== 'correlation'`, which we cannot know. Duplicate
1150
+ * literal keys resolve to the LAST, matching JavaScript.
1151
+ *
1152
+ * Anything executable ANYWHERE in the literal poisons it too, for the reason
1153
+ * given on {@link literalRunsCode}: reading one property can rewrite another,
1154
+ * so no property in such an object is a constant.
1155
+ */
1156
+ /**
1157
+ * The same two lookups again, for RECEIVERS rather than values.
1158
+ *
1159
+ * Rule 1 and rule 2 want opposite things here. Asking "is this a constant"
1160
+ * of `const handlers = { emit: Log.info }` should say no — a call through
1161
+ * `handlers` could rewrite it. Asking "could `handlers.emit(msg)` be a logger
1162
+ * call" must still say yes, because answering no silences every rule at that
1163
+ * call site, which is the one outcome worse than a false positive.
1164
+ *
1165
+ * So receiver resolution follows the binding without the mutation gate and
1166
+ * without the executable-literal gate. It keeps the key-ambiguity checks: a
1167
+ * spread or computed key really can mean the method is not the one named.
1168
+ */
1169
+ function resolveReceiverObject(context, node, seen = new Set()) {
1170
+ const current = unwrapFreeze(context, node);
1171
+ if (!current) return null;
1172
+ if (current.type === 'ObjectExpression') return current;
1173
+ if (current.type === 'Identifier') {
1174
+ const variable = resolveVariable(context, current);
1175
+ if (!variable || seen.has(variable)) return null;
1176
+ seen.add(variable);
1177
+ return resolveReceiverObject(context, bindingInit(variable), seen);
1178
+ }
1179
+ return null;
1180
+ }
1181
+
1182
+ /**
1183
+ * Every value the property `name` of this container could hold: whatever the
1184
+ * literal gives it, plus anything a later `container.name = …` assigns.
1185
+ *
1186
+ * Reading only the initializer is a bypass, because receiver resolution
1187
+ * deliberately ignores mutation:
1188
+ * `const handlers = { emit: analytics.info }; handlers.emit = Log.info`
1189
+ * resolves to the stale non-logger value and falls silent on a call that is
1190
+ * a logger call by the time it runs.
1191
+ */
1192
+ function receiverPropertyCandidates(context, node, name, callNode) {
1193
+ const candidates = [];
1194
+ const property = receiverProperty(resolveReceiverObject(context, node), name);
1195
+ if (property) candidates.push(property.value);
1196
+
1197
+ const current = unwrap(node);
1198
+ if (!current || current.type !== 'Identifier') return candidates;
1199
+ const variable = resolveVariable(context, current);
1200
+ if (!variable) return candidates;
1201
+
1202
+ for (const reference of variable.references) {
1203
+ const identifier = reference.identifier;
1204
+ const member = identifier.parent;
1205
+ if (!member || member.type !== 'MemberExpression') {
1206
+ const call = member;
1207
+ if (
1208
+ !call ||
1209
+ call.type !== 'CallExpression' ||
1210
+ !call.arguments.includes(identifier) ||
1211
+ !reachesCall(call, callNode)
1212
+ ) {
1213
+ continue;
1214
+ }
1215
+
1216
+ // `Object.assign(handlers, { emit: Log.info })`, and the same thing one
1217
+ // refactor out as `Object.assign(handlers, methods)`, install the
1218
+ // method without ever naming `handlers.emit`.
1219
+ if (
1220
+ call.arguments[0] === identifier &&
1221
+ isNamespaceMethod(context, call, 'Object', 'assign')
1222
+ ) {
1223
+ for (const source of call.arguments.slice(1)) {
1224
+ const match = receiverProperty(
1225
+ resolveReceiverObject(context, source),
1226
+ name
1227
+ );
1228
+ if (match) candidates.push(match.value);
1229
+ }
1230
+ continue;
1231
+ }
1232
+
1233
+ // `function configure(target, fn) { target.emit = fn }` called as
1234
+ // `configure(handlers, Log.info)` installs a logger this analysis does
1235
+ // not follow, and DELIBERATELY so. Following it means reproducing
1236
+ // interprocedural dataflow inside a lint rule: parameter defaults,
1237
+ // destructuring at any depth, rest patterns, spread arguments,
1238
+ // reassignment, shadowing, and every way a function can be invoked.
1239
+ // Seven review rounds went into an implementation that kept finding new
1240
+ // spellings, and the last one had started reporting ordinary code —
1241
+ // `{ [key]: () => Log.info('setup') }` is a callback that logs a
1242
+ // literal, not a logger installed on `handlers`.
1243
+ //
1244
+ // A lint rule that cries wolf gets switched off, and a rule that is
1245
+ // switched off protects nothing.
1246
+ //
1247
+ // Being in the same file is NOT what makes a logger followable — the
1248
+ // local `configure` above is exactly as invisible as an imported one.
1249
+ // What is followed is a logger that reaches the call site without
1250
+ // passing through a call: a direct call, a `Log.scoped(...)` result, a
1251
+ // `const` alias of a method, a property assignment, an object literal,
1252
+ // or `Object.assign`. Note that a call site this misses is outside ALL
1253
+ // FOUR rules, metadata keys included. The runtime redacts metadata
1254
+ // VALUES; keys are only stopped by the catalog, which is mandatory
1255
+ // under privacyDefault('private') and absent by default otherwise.
1256
+ // The README states the split in those terms.
1257
+ continue;
1258
+ }
1259
+ if (
1260
+ member.object !== identifier ||
1261
+ staticPropertyName(context, member) !== name
1262
+ ) {
1263
+ continue;
1264
+ }
1265
+ const assignment = member.parent;
1266
+ if (
1267
+ assignment &&
1268
+ assignment.type === 'AssignmentExpression' &&
1269
+ assignment.left === member &&
1270
+ reachesCall(assignment, callNode)
1271
+ ) {
1272
+ candidates.push(assignment.right);
1273
+ }
1274
+ }
1275
+ return candidates;
1276
+ }
1277
+
1278
+ /**
1279
+
1280
+ /** Is this a call to `Namespace.method(…)` on the untampered global? */
1281
+ function isNamespaceMethod(context, call, namespace, method) {
1282
+ const callee = unwrap(call.callee);
1283
+ return (
1284
+ !!callee &&
1285
+ callee.type === 'MemberExpression' &&
1286
+ builtinNamespaceOf(context, callee.object) === namespace &&
1287
+ staticPropertyName(context, callee) === method
1288
+ );
1289
+ }
1290
+
1291
+ /**
1292
+ * Could this write affect that call?
1293
+ *
1294
+ * A write earlier in the file can. So can one written later but inside a
1295
+ * function or a loop, which may run first or run again. A later write in
1296
+ * straight-line code cannot, and counting it would report the call before it
1297
+ * on evidence that does not exist yet.
1298
+ */
1299
+ function reachesCall(write, callNode) {
1300
+ if (!callNode || write.range[0] < callNode.range[0]) return true;
1301
+ // A write inside a function or loop may run first, or run again.
1302
+ if (isDeferred(write)) return true;
1303
+ // And so may the CALL: in
1304
+ // `function run() { h.emit(phi) } h.emit = Log.info; run();`
1305
+ // the write is later in the file and still happens first. Source order only
1306
+ // settles the question when both sit in straight-line code.
1307
+ return isDeferred(callNode);
1308
+ }
1309
+
1310
+ /** Is this node somewhere that can run out of source order, or more than once? */
1311
+ function isDeferred(node) {
1312
+ for (let current = node.parent; current; current = current.parent) {
1313
+ switch (current.type) {
1314
+ case 'FunctionDeclaration':
1315
+ case 'FunctionExpression':
1316
+ case 'ArrowFunctionExpression':
1317
+ case 'ForStatement':
1318
+ case 'ForOfStatement':
1319
+ case 'ForInStatement':
1320
+ case 'WhileStatement':
1321
+ case 'DoWhileStatement':
1322
+ // An INSTANCE field initializer runs at construction, which can be long
1323
+ // after the line it is written on: `class C { leak = h.emit(phi) }`
1324
+ // runs when something says `new C()`. A static field runs immediately,
1325
+ // in source order, so it settles like any other straight-line code and
1326
+ // the walk continues past it.
1327
+ // eslint-disable-next-line no-fallthrough
1328
+ case 'PropertyDefinition':
1329
+ case 'FieldDefinition':
1330
+ case 'AccessorProperty':
1331
+ if (!current.static) return true;
1332
+ break;
1333
+ default:
1334
+ break;
1335
+ }
1336
+ }
1337
+ return false;
1338
+ }
1339
+
1340
+ function receiverProperty(object, name) {
1341
+ if (!object) return null;
1342
+ let match = null;
1343
+ for (const property of object.properties) {
1344
+ // Spread and computed keys stay whole-object ambiguity: either can be the
1345
+ // key we are looking for under a spelling we cannot read.
1346
+ if (property.type === 'SpreadElement') return null;
1347
+ if (property.computed) return null;
1348
+ if (propertyKeyName(property) !== name) continue;
1349
+ // Only the MATCHING property has to be a plain value. An unrelated
1350
+ // `helper() {}` next to `emit: Log.info` says nothing about what `emit`
1351
+ // is, and discarding the whole container over it silences every rule at
1352
+ // the call site.
1353
+ if (property.kind !== 'init' || property.method) return null;
1354
+ match = property;
1355
+ }
1356
+ return match;
1357
+ }
1358
+
1359
+ function objectProperty(context, object, name) {
1360
+ if (!object) return null;
1361
+ if (literalRunsCode(context, object)) return null;
1362
+ let match = null;
1363
+ for (const property of object.properties) {
1364
+ if (property.type === 'SpreadElement') return null;
1365
+ if (property.computed) return null;
1366
+ if (propertyKeyName(property) !== name) continue;
1367
+ match = property;
1368
+ }
1369
+ return match;
1370
+ }
1371
+
1372
+ /**
1373
+ * The compile-time value of a string expression, or null if it has none.
1374
+ *
1375
+ * Returns the string rather than a boolean so that a computed member access
1376
+ * (`Log[method]`) can be resolved to the name it actually reads.
1377
+ */
1378
+ function staticStringValue(context, node, seen = new Set()) {
1379
+ const current = unwrap(node);
1380
+ if (!current) return null;
1381
+ switch (current.type) {
1382
+ case 'Literal':
1383
+ return typeof current.value === 'string' ? current.value : null;
1384
+ case 'TemplateLiteral':
1385
+ // A template with no `${}` is just a literal spelled differently.
1386
+ return current.expressions.length === 0
1387
+ ? current.quasis[0].value.cooked
1388
+ : null;
1389
+ case 'Identifier': {
1390
+ const variable = resolveVariable(context, current);
1391
+ if (!variable || seen.has(variable)) return null;
1392
+ seen.add(variable);
1393
+ // No mutability check here, deliberately. Whatever this resolves to is
1394
+ // a string, and a string cannot be changed by anything an escape could
1395
+ // do to it — member writes are no-ops on a primitive, and `immutableInit`
1396
+ // already restricts this to `const`, which forbids rebinding. Escape
1397
+ // analysis belongs to the object paths, and `MemberExpression` below
1398
+ // still goes through it.
1399
+ return staticStringValue(context, immutableInit(variable), seen);
1400
+ }
1401
+ case 'MemberExpression': {
1402
+ // `MESSAGES.startup`, where MESSAGES is a const object of literals that
1403
+ // nothing writes through and nothing else can reach.
1404
+ const name = staticPropertyName(context, current, seen);
1405
+ if (name === null) return null;
1406
+ const object = resolveObjectLiteral(context, current.object, seen);
1407
+ const property = objectProperty(context, object, name);
1408
+ return property ? staticStringValue(context, property.value, seen) : null;
1409
+ }
1410
+ default:
1411
+ return null;
1412
+ }
1413
+ }
1414
+
1415
+ /** Is this expression a compile-time-constant string? */
1416
+ function isStaticString(context, node) {
1417
+ return staticStringValue(context, node) !== null;
1418
+ }
1419
+
1420
+ /**
1421
+ * The property name of a member expression, however it is spelled: a dot, a
1422
+ * string subscript, or a subscript that resolves to a constant string.
1423
+ * `Log.info`, `Log['info']` and `const m = 'info'; Log[m]` are one thing.
1424
+ */
1425
+ function staticPropertyName(context, node, seen = new Set()) {
1426
+ if (!node || node.type !== 'MemberExpression') return null;
1427
+ if (!node.computed) {
1428
+ // `this.#logger` is as much a named field as `this.logger`; the hash is
1429
+ // visibility, not identity.
1430
+ return node.property.type === 'Identifier' ||
1431
+ node.property.type === 'PrivateIdentifier'
1432
+ ? node.property.name
1433
+ : null;
1434
+ }
1435
+ return staticStringValue(context, node.property, new Set(seen));
1436
+ }
1437
+
1438
+ /**
1439
+ * A message argument is acceptable when it is a static string, or a thunk
1440
+ * whose body is one. `() => \`patient ${id}\`` is exactly the dynamic case
1441
+ * the lazy-message API would otherwise smuggle past a naive check.
1442
+ */
1443
+ function isStaticMessage(context, node) {
1444
+ const current = unwrap(node);
1445
+ if (!current) return false;
1446
+ if (isStaticString(context, current)) return true;
1447
+ if (
1448
+ current.type === 'ArrowFunctionExpression' ||
1449
+ current.type === 'FunctionExpression'
1450
+ ) {
1451
+ if (current.body.type === 'BlockStatement') {
1452
+ const statements = current.body.body;
1453
+ if (statements.length !== 1 || statements[0].type !== 'ReturnStatement') {
1454
+ return false;
1455
+ }
1456
+ return isStaticString(context, statements[0].argument);
1457
+ }
1458
+ return isStaticString(context, current.body);
1459
+ }
1460
+ return false;
1461
+ }
1462
+
1463
+ /* -------------------------------------------------------------------------
1464
+ * Receiver classification
1465
+ * ---------------------------------------------------------------------- */
1466
+
1467
+ /**
1468
+ * Classify a call's receiver: the Logger singleton, a ScopedLogger, or
1469
+ * something carrying a logger name that resolves to neither.
1470
+ *
1471
+ * Argument roles differ between the two shapes — `Log.info(msg, meta, sub)`
1472
+ * vs `scope.info(msg, meta)`, `Log.log(msg, options)` vs `scope.log(msg,
1473
+ * level, meta)` — so rules that key off position must know which they have.
1474
+ *
1475
+ * Resolution happens BEFORE the name check. `const logger = Log.scoped()` is
1476
+ * a ScopedLogger whatever it is called, and trusting the spelling first is
1477
+ * exactly how its metadata argument goes unexamined.
1478
+ *
1479
+ * The distinction between `'logger'` and `'ambiguous'` is provenance, not
1480
+ * confidence: an import of the singleton, or a bare global spelled with its
1481
+ * documented name, IS the Logger. A parameter or a property that merely
1482
+ * happens to be called `logger` could be anything, and correlation
1483
+ * provenance in particular must not trust it.
1484
+ *
1485
+ * @returns {'logger' | 'scoped' | 'ambiguous' | null}
1486
+ */
1487
+ function classifyReceiver(context, node, seen = new Set()) {
1488
+ const current = unwrap(node);
1489
+ if (!current) return null;
1490
+
1491
+ if (current.type === 'Identifier') {
1492
+ const variable = resolveVariable(context, current);
1493
+ if (variable && !seen.has(variable)) {
1494
+ seen.add(variable);
1495
+ const def = singleDef(variable);
1496
+
1497
+ // `import { Log } from 'react-native-nitro-logger'`, possibly renamed.
1498
+ if (def && def.type === 'ImportBinding') {
1499
+ if (isLoggerImport(context, def)) return 'logger';
1500
+ // Same name, unknown module: still worth CHECKING its calls, never
1501
+ // worth TRUSTING as the singleton.
1502
+ const imported = def.node.imported?.name ?? def.node.local?.name;
1503
+ return loggerNames(context).has(imported) ? 'ambiguous' : null;
1504
+ }
1505
+
1506
+ const init = bindingInit(variable);
1507
+ if (init) return classifyReceiver(context, init, seen);
1508
+
1509
+ // A parameter, or a binding we cannot pin down. The name is a hint,
1510
+ // never proof.
1511
+ return loggerNames(context).has(current.name) ? 'ambiguous' : null;
1512
+ }
1513
+ if (seen.has(variable)) return null;
1514
+ // Unresolved global. Only the canonical name is the singleton itself.
1515
+ if (current.name === canonicalLoggerName(context)) return 'logger';
1516
+ return loggerNames(context).has(current.name) ? 'ambiguous' : null;
1517
+ }
1518
+
1519
+ // `this.logger.info(…)`, `deps.log.info(…)` — very common, and the property
1520
+ // name is the only signal available, so it can only ever be a hint.
1521
+ if (current.type === 'MemberExpression') {
1522
+ const name = staticPropertyName(context, current);
1523
+ return name !== null && loggerNames(context).has(name) ? 'ambiguous' : null;
1524
+ }
1525
+
1526
+ // `Log.scoped(…)` and `scope.scoped(…)` both produce a ScopedLogger.
1527
+ if (current.type === 'CallExpression') {
1528
+ const callee = unwrap(current.callee);
1529
+ if (
1530
+ callee &&
1531
+ callee.type === 'MemberExpression' &&
1532
+ staticPropertyName(context, callee) === 'scoped' &&
1533
+ classifyReceiver(context, callee.object, seen) !== null
1534
+ ) {
1535
+ return 'scoped';
1536
+ }
1537
+ return null;
1538
+ }
1539
+
1540
+ return null;
1541
+ }
1542
+
1543
+ /** Could this receiver be the Logger singleton? */
1544
+ function maybeLogger(receiver) {
1545
+ return receiver === 'logger' || receiver === 'ambiguous';
1546
+ }
1547
+
1548
+ /**
1549
+ * Is this expression provably the Logger the package ships?
1550
+ *
1551
+ * Stricter than `classifyReceiver(...) === 'logger'`, and deliberately so.
1552
+ * Classification exists to decide what to CHECK, where a bare global named
1553
+ * `Log` is a fine hint. This exists to decide what to TRUST — currently only
1554
+ * `newCorrelationId`'s provenance — and a name that nothing in the file
1555
+ * defines proves nothing at all: `globalThis.Log = fakeLogger` satisfies it.
1556
+ * Only a binding traceable to an import of the real package counts.
1557
+ */
1558
+ function isTrustedLogger(context, node, seen = new Set()) {
1559
+ const current = unwrap(node);
1560
+ if (!current) return false;
1561
+
1562
+ // `Log.scoped(id, 'net')` — a scope minted by a trusted Logger inherits its
1563
+ // trust, so `scope.info(M)` stays exempt from mutation analysis.
1564
+ if (current.type === 'CallExpression') {
1565
+ const callee = unwrap(current.callee);
1566
+ return (
1567
+ !!callee &&
1568
+ callee.type === 'MemberExpression' &&
1569
+ staticPropertyName(context, callee) === 'scoped' &&
1570
+ isTrustedLogger(context, callee.object, seen)
1571
+ );
1572
+ }
1573
+
1574
+ if (current.type !== 'Identifier') return false;
1575
+ const variable = resolveVariable(context, current);
1576
+ if (!variable || seen.has(variable)) return false;
1577
+ seen.add(variable);
1578
+
1579
+ // The binding has to still hold what it was given. A rebind
1580
+ // (`let L = Log; L = fakeLogger`), a write through it
1581
+ // (`Log.newCorrelationId = () => patient.mrn`), or an escape into code we
1582
+ // cannot see all mean the method we are about to trust may not be the one
1583
+ // the package shipped.
1584
+ if (isMutable(context, variable)) return false;
1585
+
1586
+ const def = singleDef(variable);
1587
+ if (def && def.type === 'ImportBinding') return isLoggerImport(context, def);
1588
+ // `const` only: `immutableInit` rather than `bindingInit`, because the
1589
+ // latter reports a declaration initializer that a later write has replaced.
1590
+ return isTrustedLogger(context, immutableInit(variable), seen);
1591
+ }
1592
+
1593
+ /* -------------------------------------------------------------------------
1594
+ * Call description
1595
+ * ---------------------------------------------------------------------- */
1596
+
1597
+ function hasSpread(args) {
1598
+ return args.some((a) => a.type === 'SpreadElement');
1599
+ }
1600
+
1601
+ /** `Log.info`, `Log['info']`, `Log.info.bind(Log)` → a described method. */
1602
+ function describeMethodReference(context, node, args) {
1603
+ const current = unwrap(node);
1604
+ if (!current) return null;
1605
+
1606
+ if (current.type === 'MemberExpression') {
1607
+ const method = staticPropertyName(context, current);
1608
+ if (method === null || !API_METHODS.has(method)) return null;
1609
+ const receiver = classifyReceiver(context, current.object);
1610
+ if (receiver === null) return null;
1611
+ return { method, receiver, args, spreadArgs: hasSpread(args) };
1612
+ }
1613
+
1614
+ // A partially applied bind shifts every position, so it is reported as
1615
+ // unanalyzable rather than trusted.
1616
+ if (current.type === 'CallExpression') {
1617
+ const callee = unwrap(current.callee);
1618
+ if (
1619
+ callee &&
1620
+ callee.type === 'MemberExpression' &&
1621
+ staticPropertyName(context, callee) === 'bind'
1622
+ ) {
1623
+ const inner = describeMethodReference(context, callee.object, args);
1624
+ if (!inner) return null;
1625
+ return {
1626
+ ...inner,
1627
+ spreadArgs: inner.spreadArgs || current.arguments.length > 1,
1628
+ };
1629
+ }
1630
+ }
1631
+
1632
+ return null;
1633
+ }
1634
+
1635
+ /**
1636
+ * Describe any call into the logger's public API — level helpers, `log`,
1637
+ * `scoped`, `subsystem`, `resetSubsystem` — normalizing away
1638
+ * `.call`/`.apply`/`.bind`, computed access, destructured methods and method
1639
+ * aliases so every rule sees one shape.
1640
+ *
1641
+ * Returns `{ method, receiver, args, spreadArgs }` where `args` are the
1642
+ * logical arguments with any `thisArg` stripped, or null if this is not an
1643
+ * API call. `spreadArgs` means the call exists but its arguments cannot be
1644
+ * matched to positions — which is a finding, not a reason to stay quiet.
1645
+ */
1646
+ function describeCall(context, node) {
1647
+ const callee = unwrap(node.callee);
1648
+ if (!callee) return null;
1649
+
1650
+ if (callee.type === 'MemberExpression') {
1651
+ // `Log.info.call(thisArg, message, …)` / `.apply(thisArg, argsArray)`
1652
+ const outer = staticPropertyName(context, callee);
1653
+ if (outer === 'call' || outer === 'apply') {
1654
+ const inner = unwrap(callee.object);
1655
+ if (inner && inner.type === 'MemberExpression') {
1656
+ const method = staticPropertyName(context, inner);
1657
+ if (method !== null && API_METHODS.has(method)) {
1658
+ const receiver = classifyReceiver(context, inner.object);
1659
+ if (receiver === null) return null;
1660
+ const args = outer === 'call' ? node.arguments.slice(1) : [];
1661
+ return {
1662
+ method,
1663
+ receiver,
1664
+ // `.apply` passes an array — positional analysis is meaningless,
1665
+ // so report only that the call exists.
1666
+ args,
1667
+ spreadArgs: outer === 'apply' || hasSpread(args),
1668
+ };
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ const method = staticPropertyName(context, callee);
1674
+ const receiver = classifyReceiver(context, callee.object);
1675
+
1676
+ if (method !== null && API_METHODS.has(method)) {
1677
+ if (receiver === null) return null;
1678
+ return {
1679
+ method,
1680
+ receiver,
1681
+ args: node.arguments,
1682
+ spreadArgs: hasSpread(node.arguments),
1683
+ };
1684
+ }
1685
+
1686
+ // `Log['in' + 'fo'](…)` / `Log[pick()](…)` — the receiver is a logger and
1687
+ // the method is unreadable, so we cannot rule out that this is a log
1688
+ // call. That is a finding, not a reason to fall silent.
1689
+ if (method === null && callee.computed && receiver !== null) {
1690
+ return {
1691
+ method: null,
1692
+ receiver,
1693
+ args: node.arguments,
1694
+ spreadArgs: true,
1695
+ opaqueMethod: true,
1696
+ };
1697
+ }
1698
+
1699
+ // `const handlers = { emit: Log.info }; handlers.emit(msg)` — the method
1700
+ // lives in a const object rather than a binding of its own.
1701
+ if (method !== null) {
1702
+ const candidates = receiverPropertyCandidates(
1703
+ context,
1704
+ callee.object,
1705
+ method,
1706
+ node
1707
+ );
1708
+ const described = candidates
1709
+ .map((value) => describeMethodReference(context, value, node.arguments))
1710
+ .filter(Boolean);
1711
+ // Exactly one possible value, and it is a logger method: analyze it.
1712
+ if (candidates.length === 1 && described.length === 1)
1713
+ return described[0];
1714
+ // Several possible values and at least one is a logger method. Which
1715
+ // one runs is unknowable, so this is a finding rather than a guess.
1716
+ if (described.length > 0) {
1717
+ return {
1718
+ method: null,
1719
+ receiver: described[0].receiver,
1720
+ args: node.arguments,
1721
+ spreadArgs: true,
1722
+ opaqueMethod: true,
1723
+ };
1724
+ }
1725
+ }
1726
+ return null;
1727
+ }
1728
+
1729
+ // `(Log.info.bind(Log))(msg)` — the callee is the bind call itself.
1730
+ if (callee.type === 'CallExpression') {
1731
+ return describeMethodReference(context, callee, node.arguments);
1732
+ }
1733
+
1734
+ if (callee.type !== 'Identifier') return null;
1735
+
1736
+ const variable = resolveVariable(context, callee);
1737
+ const def = singleDef(variable);
1738
+ if (!def || def.type !== 'Variable') return null;
1739
+
1740
+ const pattern = def.node.id;
1741
+
1742
+ // `const { info } = Log` / `{ info: emit }` / `{ info = fallback }`
1743
+ if (pattern.type === 'ObjectPattern') {
1744
+ const property = pattern.properties.find((p) => {
1745
+ if (p.type !== 'Property') return false;
1746
+ const target =
1747
+ p.value.type === 'AssignmentPattern' ? p.value.left : p.value;
1748
+ return target === def.name;
1749
+ });
1750
+ if (!property) return null;
1751
+ const method = propertyKeyName(property);
1752
+ if (method === null || !API_METHODS.has(method)) return null;
1753
+ const receiver = classifyReceiver(context, def.node.init);
1754
+ if (receiver === null) return null;
1755
+ return {
1756
+ method,
1757
+ receiver,
1758
+ args: node.arguments,
1759
+ spreadArgs: hasSpread(node.arguments),
1760
+ };
1761
+ }
1762
+
1763
+ // `const emit = Log.info` and `const [emit] = [Log.info]`, plus the `.bind`
1764
+ // form of either.
1765
+ return describeMethodReference(
1766
+ context,
1767
+ bindingInit(variable),
1768
+ node.arguments
1769
+ );
1770
+ }
1771
+
1772
+ /**
1773
+ * An API call restricted to the message-emitting methods — plus calls whose
1774
+ * method name could not be read at all, since one of the things they could be
1775
+ * is a log call.
1776
+ */
1777
+ function describeLogCall(context, node) {
1778
+ const call = describeCall(context, node);
1779
+ if (!call) return null;
1780
+ return LOG_METHODS.has(call.method) || call.opaqueMethod ? call : null;
1781
+ }
1782
+
1783
+ /** `Log.scoped(correlation, subsystem, metadata)` or `scope.scoped(…)`. */
1784
+ function describeScopedCall(context, node) {
1785
+ const call = describeCall(context, node);
1786
+ return call && call.method === 'scoped' ? call : null;
1787
+ }
1788
+
1789
+ /** `Log.subsystem(name, level)` / `Log.resetSubsystem(name)`. */
1790
+ function describeSubsystemConfigCall(context, node) {
1791
+ const call = describeCall(context, node);
1792
+ if (!call || !CONFIG_METHODS.has(call.method)) return null;
1793
+ // ScopedLogger has no level-configuration API.
1794
+ return maybeLogger(call.receiver) ? call : null;
1795
+ }
1796
+
1797
+ /* -------------------------------------------------------------------------
1798
+ * Argument-position modeling
1799
+ * ---------------------------------------------------------------------- */
1800
+
1801
+ const ABSENT = { kind: 'absent' };
1802
+ const found = (node) => ({ kind: 'value', node });
1803
+ const opaque = (node) => ({ kind: 'unanalyzable', node });
1804
+
1805
+ /** Nothing passed in this position, or an explicit "no value". */
1806
+ function isOmitted(context, node) {
1807
+ const current = unwrap(node);
1808
+ if (!current) return true;
1809
+ if (current.type === 'Literal' && current.value === null) return true;
1810
+ if (current.type === 'Identifier' && current.name === 'undefined') {
1811
+ // Only the real global; a local named `undefined` is someone's problem.
1812
+ const variable = resolveVariable(context, current);
1813
+ return !variable || variable.defs.length === 0;
1814
+ }
1815
+ return false;
1816
+ }
1817
+
1818
+ /**
1819
+ * A named property of an options object, following `const` aliases.
1820
+ *
1821
+ * @returns {{kind:'value',node}|{kind:'absent'}|{kind:'unanalyzable',node}}
1822
+ */
1823
+ function optionsProperty(context, node, name) {
1824
+ const current = unwrap(node);
1825
+ if (isOmitted(context, current)) return ABSENT;
1826
+ const object = resolveObjectLiteral(context, current);
1827
+ if (!object) return opaque(current);
1828
+ // A spread or a computed key can introduce or override this field under a
1829
+ // spelling we cannot read, so neither can be reasoned past.
1830
+ const wildcard = object.properties.find(
1831
+ (p) => p.type === 'SpreadElement' || p.computed
1832
+ );
1833
+ if (wildcard) return opaque(wildcard.key ?? wildcard);
1834
+ const property = objectProperty(context, object, name);
1835
+ if (!property) return ABSENT;
1836
+ // `{ subsystem: undefined }` states the field is absent, which is what the
1837
+ // runtime sees too.
1838
+ return isOmitted(context, property.value) ? ABSENT : found(property.value);
1839
+ }
1840
+
1841
+ /**
1842
+ * `Log.log(msg, options)` and `scope.log(msg, level, meta)` share a name.
1843
+ * When the receiver is ambiguous the second argument decides: a bare level
1844
+ * string means the ScopedLogger shape, an object literal means the Logger
1845
+ * shape, and anything else — including an omitted argument, since a scope's
1846
+ * level is defaulted — means both have to be checked.
1847
+ */
1848
+ function logCallShape({ receiver, args, method }, context) {
1849
+ // `logMessage` exists only on the Logger and takes exactly one shape,
1850
+ // `(message, options)`. There is no ScopedLogger overload to disambiguate
1851
+ // against, so it never needs the argument sniffing below.
1852
+ if (method === 'logMessage') return 'logger';
1853
+ if (receiver === 'scoped') return 'scoped';
1854
+ if (receiver === 'logger') return 'logger';
1855
+ const second = unwrap(args[1]);
1856
+ const level = staticStringValue(context, second);
1857
+ if (level !== null && LEVEL_METHODS.has(level)) return 'scoped';
1858
+ if (second && second.type === 'ObjectExpression') return 'logger';
1859
+ return 'both';
1860
+ }
1861
+
1862
+ /**
1863
+ * Every argument position carrying metadata for this call.
1864
+ *
1865
+ * `Log.<level>(message, metadata, subsystem)` · `scope.<level>(message,
1866
+ * metadata)` · `scope.log(message, level, metadata)` · `Log.log(message,
1867
+ * { metadata })`.
1868
+ */
1869
+ function metadataArguments(context, call) {
1870
+ // Spread is handled by the rule, which has the call node to report on.
1871
+ if (call.spreadArgs) return [];
1872
+ const { method, args } = call;
1873
+ if (!OPTIONS_METHODS.has(method)) {
1874
+ return isOmitted(context, args[1]) ? [] : [found(args[1])];
1875
+ }
1876
+
1877
+ const shape = logCallShape(call, context);
1878
+ const results = [];
1879
+ if (shape === 'scoped' || shape === 'both') {
1880
+ if (!isOmitted(context, args[2])) results.push(found(args[2]));
1881
+ }
1882
+ if (shape === 'logger' || shape === 'both') {
1883
+ for (const field of METADATA_OPTION_FIELDS) {
1884
+ const property = optionsProperty(context, args[1], field);
1885
+ if (property.kind !== 'absent') results.push(property);
1886
+ }
1887
+ }
1888
+ return results;
1889
+ }
1890
+
1891
+ /** Positions carrying an options-object field of the Logger shape. */
1892
+ function loggerOptionField(context, call, name) {
1893
+ if (!OPTIONS_METHODS.has(call.method)) return [];
1894
+ const shape = logCallShape(call, context);
1895
+ if (shape === 'scoped') return [];
1896
+ const property = optionsProperty(context, call.args[1], name);
1897
+ return property.kind === 'absent' ? [] : [property];
1898
+ }
1899
+
1900
+ /** Argument positions carrying a subsystem name. */
1901
+ function subsystemArguments(context, call) {
1902
+ const { method, receiver, args } = call;
1903
+ if (!OPTIONS_METHODS.has(method)) {
1904
+ // Only the Logger's level helpers take a subsystem third argument;
1905
+ // ScopedLogger inherits it and has no such parameter.
1906
+ if (!maybeLogger(receiver)) return [];
1907
+ if (call.spreadArgs) return [];
1908
+ return isOmitted(context, args[2]) ? [] : [found(args[2])];
1909
+ }
1910
+ if (call.spreadArgs) return [];
1911
+ return loggerOptionField(context, call, 'subsystem');
1912
+ }
1913
+
1914
+ /** Argument positions carrying a correlation ID. */
1915
+ function correlationArguments(context, call) {
1916
+ if (call.spreadArgs) return [];
1917
+ return loggerOptionField(context, call, 'correlation');
1918
+ }
1919
+
1920
+ module.exports = {
1921
+ API_METHODS,
1922
+ CONFIG_METHODS,
1923
+ LEVEL_METHODS,
1924
+ LOGGER_OWN_METHODS,
1925
+ LOG_METHODS,
1926
+ classifyReceiver,
1927
+ correlationArguments,
1928
+ describeCall,
1929
+ describeLogCall,
1930
+ describeScopedCall,
1931
+ describeSubsystemConfigCall,
1932
+ isMutable,
1933
+ isOmitted,
1934
+ isStaticMessage,
1935
+ isStaticString,
1936
+ isTrustedLogger,
1937
+ maybeLogger,
1938
+ metadataArguments,
1939
+ objectProperty,
1940
+ optionsProperty,
1941
+ resolveObjectLiteral,
1942
+ resolveVariable,
1943
+ singleDef,
1944
+ staticPropertyName,
1945
+ staticStringValue,
1946
+ subsystemArguments,
1947
+ unwrap,
1948
+ };