@openclaw/plugin-inspector 0.3.13 → 0.3.14

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/CHANGELOG.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.3.14 - 2026-06-11
4
+
5
+ ### Changed
6
+
7
+ - Flag deprecated `loadSessionStore(...)` whole-store session helper usage as an author-facing deprecation warning while keeping speculative transcript-identity migration rules out of default inspection. Thanks @jalehman.
4
8
 
5
9
  ## 0.3.13 - 2026-06-09
6
10
 
package/README.md CHANGED
@@ -299,7 +299,7 @@ Important report sections:
299
299
  | `status` | `pass` unless hard breakages exist. |
300
300
  | `summary` | Counts for fixtures, breakages, warnings, suggestions, issues, issue classes, and contract probes. |
301
301
  | `targetOpenClaw` | Status and public compatibility data read from the optional OpenClaw checkout. |
302
- | `fixtures` | Per-plugin metadata, hooks, registrations, manifest contracts, package data, and SDK imports. |
302
+ | `fixtures` | Per-plugin metadata, hooks, registrations, manifest contracts, package data, SDK imports, and SDK deprecation evidence. |
303
303
  | `breakages` | Blocking compatibility failures. |
304
304
  | `warnings` / `suggestions` | Non-blocking compatibility findings. |
305
305
  | `issues` | Normalized issue rows with severity and class. |
@@ -311,6 +311,9 @@ Default `check`, `ci`, and `batch` reports include both author-facing and
311
311
  internal findings. Pass `--author-facing` when producing plugin-author output;
312
312
  that filtered view includes only findings with `authorRemediation.summary` and
313
313
  `authorRemediation.docsUrl`.
314
+ Current author-facing deprecation warnings include deprecated SDK helpers such
315
+ as `loadSessionStore(...)` when a plugin still depends on the legacy whole-store
316
+ session shape.
314
317
 
315
318
  ## CI Policy And Shared Reporting Primitives
316
319
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
@@ -49,6 +49,7 @@ export async function buildCompatibilityFixtureReport({ fixture, inspection, che
49
49
  packages: packageSummaries,
50
50
  sdkImports,
51
51
  sdkImportDetails: inspection.sdkImports ?? [],
52
+ sdkDeprecations: inspection.sdkDeprecations ?? [],
52
53
  };
53
54
  }
54
55
 
@@ -496,6 +497,7 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
496
497
  logs.push(...packageContracts.logs);
497
498
  decisions.push(...packageContracts.decisions);
498
499
  classifySecurityManifestCoverage({ fixture, fixtureReport, warnings, decisions });
500
+ classifySdkDeprecations({ fixture, inspection, fixtureReport, warnings, decisions });
499
501
 
500
502
  for (const pluginManifest of fixtureReport.pluginManifests) {
501
503
  const providerAuthKeys = Object.keys(pluginManifest.providerAuthEnvVars ?? {});
@@ -702,6 +704,34 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
702
704
  return { warnings, suggestions, logs, decisions };
703
705
  }
704
706
 
707
+ function classifySdkDeprecations({ fixture, inspection, fixtureReport, warnings, decisions }) {
708
+ const grouped = new Map();
709
+ for (const finding of fixtureReport.sdkDeprecations ?? inspection.sdkDeprecations ?? []) {
710
+ const existing = grouped.get(finding.code) ?? [];
711
+ existing.push(finding);
712
+ grouped.set(finding.code, existing);
713
+ }
714
+
715
+ for (const [code, findings] of grouped) {
716
+ const first = findings[0];
717
+ warnings.push({
718
+ fixture: fixture.id,
719
+ code,
720
+ level: "warning",
721
+ message: first.message,
722
+ evidence: findings.map((finding) => `${finding.surface} @ ${finding.ref}`),
723
+ });
724
+ decisions.push({
725
+ fixture: fixture.id,
726
+ decision: "core-compat-adapter",
727
+ seam: "session-store",
728
+ action:
729
+ "Keep loadSessionStore compatibility active while plugin authors migrate to row-scoped session helpers.",
730
+ evidence: findings.map((finding) => finding.ref).join(", "),
731
+ });
732
+ }
733
+ }
734
+
705
735
  function classifySecurityManifestCoverage({ fixture, fixtureReport, warnings, decisions }) {
706
736
  for (const securityManifest of fixtureReport.securityManifests ?? []) {
707
737
  warnings.push({
package/src/inspector.js CHANGED
@@ -10,6 +10,7 @@ import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
10
10
  import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
11
11
  import { readOpenClawTargetSurface } from "./openclaw-target.js";
12
12
  import { buildCompatibilityReport, buildReport } from "./report.js";
13
+ import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js";
13
14
 
14
15
  const execFileAsync = promisify(execFile);
15
16
  const registrationEquivalents = new Map([
@@ -104,6 +105,7 @@ export async function inspectPlugin(fixture, options = {}) {
104
105
  const hookDetails = [];
105
106
  const registrationDetails = [];
106
107
  const sdkImportDetails = [];
108
+ const sdkDeprecationDetails = [];
107
109
 
108
110
  for (const filePath of files) {
109
111
  const text = await readFile(filePath, "utf8");
@@ -121,6 +123,9 @@ export async function inspectPlugin(fixture, options = {}) {
121
123
  for (const sdkImport of sourceInspection.sdkImports) {
122
124
  sdkImportDetails.push(sdkImport);
123
125
  }
126
+ for (const sdkDeprecation of sourceInspection.sdkDeprecations) {
127
+ sdkDeprecationDetails.push(sdkDeprecation);
128
+ }
124
129
  }
125
130
 
126
131
  const manifestInspection = await readManifestContracts(config, checkoutPath, sourceRoot);
@@ -140,6 +145,7 @@ export async function inspectPlugin(fixture, options = {}) {
140
145
  packageErrors: packageInspection.errors,
141
146
  packageEntrypoints: packageInspection.entrypoints,
142
147
  sdkImports: uniqueDetails(sdkImportDetails),
148
+ sdkDeprecations: uniqueSdkDeprecations(sdkDeprecationDetails),
143
149
  sourceFiles: files.map((filePath) => path.relative(config.rootDir ?? process.cwd(), filePath)).sort(),
144
150
  };
145
151
  }
@@ -160,11 +166,13 @@ export function inspectSourceText(text, filePath = "source.js") {
160
166
  filePath,
161
167
  "specifier",
162
168
  );
169
+ const sdkDeprecations = inspectSdkDeprecations(searchableText, filePath);
163
170
 
164
171
  return {
165
172
  hooks,
166
173
  registrations,
167
174
  sdkImports,
175
+ sdkDeprecations,
168
176
  };
169
177
  }
170
178
 
@@ -353,6 +361,7 @@ function emptyInspection(fixture, status) {
353
361
  packageErrors: [],
354
362
  packageEntrypoints: [],
355
363
  sdkImports: [],
364
+ sdkDeprecations: [],
356
365
  sourceFiles: [],
357
366
  };
358
367
  }
@@ -554,3 +563,11 @@ function uniqueDetails(details) {
554
563
  }
555
564
  return [...byKey.values()];
556
565
  }
566
+
567
+ function uniqueSdkDeprecations(details) {
568
+ const byKey = new Map();
569
+ for (const detail of [...details].sort((left, right) => left.ref.localeCompare(right.ref))) {
570
+ byKey.set(`${detail.code}:${detail.surface}:${detail.ref}`, detail);
571
+ }
572
+ return [...byKey.values()];
573
+ }
package/src/issues.js CHANGED
@@ -41,6 +41,7 @@ export const knownIssueCodes = new Set([
41
41
  "runtime-tool-capture",
42
42
  "reserved-sdk-import",
43
43
  "security-manifest-schema-unavailable",
44
+ "sdk-load-session-store",
44
45
  "sdk-export-missing",
45
46
  "unrecognized-security-manifest",
46
47
  ]);
@@ -110,6 +111,19 @@ export const issueMetadataByCode = {
110
111
  ],
111
112
  ),
112
113
  },
114
+ "sdk-load-session-store": {
115
+ severity: "P2",
116
+ owner: "core",
117
+ decision: "core-compat-adapter",
118
+ title: "deprecated whole-store session helper is still used",
119
+ authorRemediation: migrationRemediation(
120
+ "Replace deprecated loadSessionStore whole-store access with row-scoped session helpers.",
121
+ [
122
+ "Use getSessionEntry(...) or listSessionEntries(...) for reads instead of cloning the whole session store.",
123
+ "Use patchSessionEntry(...) or upsertSessionEntry(...) for writes instead of mutating and saving a whole-store object.",
124
+ ],
125
+ ),
126
+ },
113
127
  "sdk-export-missing": {
114
128
  severity: "P1",
115
129
  owner: "core",
@@ -545,7 +559,16 @@ function issueClassFor(code, options) {
545
559
  if (code === "missing-compat-record") {
546
560
  return "compat-gap";
547
561
  }
548
- if (options.deprecated || ["channel-env-vars", "legacy-before-agent-start", "legacy-root-sdk-import", "provider-auth-env-vars"].includes(code)) {
562
+ if (
563
+ options.deprecated ||
564
+ [
565
+ "channel-env-vars",
566
+ "legacy-before-agent-start",
567
+ "legacy-root-sdk-import",
568
+ "provider-auth-env-vars",
569
+ "sdk-load-session-store",
570
+ ].includes(code)
571
+ ) {
549
572
  return "deprecation-warning";
550
573
  }
551
574
  if (
package/src/report.js CHANGED
@@ -26,6 +26,7 @@ export function buildReport({ config, inspections, failures = [], generatedAt =
26
26
  registrations: inspection.registrations,
27
27
  manifestContracts: inspection.manifestContracts,
28
28
  sdkImports: inspection.sdkImports,
29
+ sdkDeprecations: inspection.sdkDeprecations,
29
30
  sourceFiles: inspection.sourceFiles,
30
31
  manifestFiles: inspection.manifestFiles,
31
32
  packageFiles: inspection.packageFiles,
@@ -477,6 +478,7 @@ function defaultCompatibilityFixtureReport({ fixture, inspection }) {
477
478
  packages: [],
478
479
  sdkImports: inspection.sdkImports.map((sdkImport) => sdkImport.specifier).filter(Boolean),
479
480
  sdkImportDetails: inspection.sdkImports,
481
+ sdkDeprecations: inspection.sdkDeprecations,
480
482
  };
481
483
  }
482
484
 
@@ -495,6 +497,7 @@ function normalizeInspection(inspection, fixture) {
495
497
  packageErrors: [],
496
498
  packageEntrypoints: [],
497
499
  sdkImports: [],
500
+ sdkDeprecations: [],
498
501
  sourceFiles: [],
499
502
  ...inspection,
500
503
  };
@@ -0,0 +1,369 @@
1
+ const loadSessionStoreReplacement =
2
+ "getSessionEntry(...) / listSessionEntries(...) for reads and patchSessionEntry(...) / upsertSessionEntry(...) for writes";
3
+
4
+ const loadSessionStoreSpecifiers = new Set([
5
+ "openclaw/plugin-sdk/config-runtime",
6
+ "openclaw/plugin-sdk/session-store-runtime",
7
+ ]);
8
+
9
+ export const pluginSdkDeprecationRules = [
10
+ {
11
+ code: "sdk-load-session-store",
12
+ title: "deprecated whole-store session helper is still used",
13
+ replacement: loadSessionStoreReplacement,
14
+ },
15
+ ];
16
+
17
+ export function inspectSdkDeprecations(text, filePath = "source.js", rules = pluginSdkDeprecationRules) {
18
+ const findings = [];
19
+
20
+ for (const rule of rules) {
21
+ if (rule.code === "sdk-load-session-store") {
22
+ collectLoadSessionStoreDeprecations(findings, { text, filePath, rule });
23
+ }
24
+ }
25
+
26
+ return uniqueFindings(findings)
27
+ .sort((left, right) => left.offset - right.offset || left.surface.localeCompare(right.surface))
28
+ .map(({ offset, ...finding }) => finding);
29
+ }
30
+
31
+ function collectLoadSessionStoreDeprecations(findings, context) {
32
+ collectNamedImportDeprecations(findings, context);
33
+ collectNamedReexportDeprecations(findings, context);
34
+ collectNamedRequireDeprecations(findings, context);
35
+ collectNamespaceUsageDeprecations(findings, context);
36
+ collectNamespaceRequireDeprecations(findings, context);
37
+ collectRuntimeUsageDeprecations(findings, context);
38
+ }
39
+
40
+ function collectNamedImportDeprecations(findings, context) {
41
+ const regex =
42
+ /\bimport\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?{([^}]+)}\s*from\s*["'`]([^"'`]+)["'`]/g;
43
+ for (const match of context.text.matchAll(regex)) {
44
+ const specifier = match[2];
45
+ if (!loadSessionStoreSpecifiers.has(specifier)) {
46
+ continue;
47
+ }
48
+ for (const binding of parseNamedBindings(match[1])) {
49
+ if (binding.exported !== "loadSessionStore") {
50
+ continue;
51
+ }
52
+ findings.push(
53
+ buildFinding(context.rule, {
54
+ surface: `${specifier} import`,
55
+ sourceText: context.text,
56
+ filePath: context.filePath,
57
+ offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
58
+ }),
59
+ );
60
+ }
61
+ }
62
+ }
63
+
64
+ function collectNamedReexportDeprecations(findings, context) {
65
+ const regex = /\bexport\s*{([^}]+)}\s*from\s*["'`]([^"'`]+)["'`]/g;
66
+ for (const match of context.text.matchAll(regex)) {
67
+ const specifier = match[2];
68
+ if (!loadSessionStoreSpecifiers.has(specifier)) {
69
+ continue;
70
+ }
71
+ for (const binding of parseNamedBindings(match[1])) {
72
+ if (binding.exported !== "loadSessionStore") {
73
+ continue;
74
+ }
75
+ findings.push(
76
+ buildFinding(context.rule, {
77
+ surface: `${specifier} re-export`,
78
+ sourceText: context.text,
79
+ filePath: context.filePath,
80
+ offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
81
+ }),
82
+ );
83
+ }
84
+ }
85
+ }
86
+
87
+ function collectNamedRequireDeprecations(findings, context) {
88
+ const regex = /\b(?:const|let|var)\s+{([^}]+)}\s*=\s*require\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
89
+ for (const match of context.text.matchAll(regex)) {
90
+ const specifier = match[2];
91
+ if (!loadSessionStoreSpecifiers.has(specifier)) {
92
+ continue;
93
+ }
94
+ for (const binding of parseNamedBindings(match[1], { aliasSeparator: ":" })) {
95
+ if (binding.exported !== "loadSessionStore") {
96
+ continue;
97
+ }
98
+ findings.push(
99
+ buildFinding(context.rule, {
100
+ surface: `${specifier} require`,
101
+ sourceText: context.text,
102
+ filePath: context.filePath,
103
+ offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
104
+ }),
105
+ );
106
+ }
107
+ }
108
+ }
109
+
110
+ function collectMemberCallDeprecations(findings, context, options) {
111
+ forEachMethodCall(context.text, "loadSessionStore", (offset) => {
112
+ // Normalize transparent parentheses and optional-chained member links before matching.
113
+ const receiver = readNormalizedCallReceiver(context.text, offset);
114
+ if (!receiver || !options.receiverMatcher(receiver)) {
115
+ return;
116
+ }
117
+ findings.push(
118
+ buildFinding(context.rule, {
119
+ surface: options.surface,
120
+ sourceText: context.text,
121
+ filePath: context.filePath,
122
+ offset,
123
+ }),
124
+ );
125
+ });
126
+ }
127
+
128
+ function forEachMethodCall(text, methodName, visit) {
129
+ let start = 0;
130
+ while (start < text.length) {
131
+ const offset = text.indexOf(methodName, start);
132
+ if (offset === -1) {
133
+ return;
134
+ }
135
+ start = offset + methodName.length;
136
+ if (!isIdentifierBoundary(text, offset - 1) || !isIdentifierBoundary(text, offset + methodName.length)) {
137
+ continue;
138
+ }
139
+ if (!hasCallSuffix(text, offset + methodName.length)) {
140
+ continue;
141
+ }
142
+ visit(offset);
143
+ }
144
+ }
145
+
146
+ function readNormalizedCallReceiver(text, methodOffset) {
147
+ let cursor = skipWhitespaceBackward(text, methodOffset);
148
+ const operator = readMemberAccessOperator(text, cursor);
149
+ if (!operator) {
150
+ return null;
151
+ }
152
+ cursor = operator.start;
153
+ const receiverStart = findReceiverStart(text, cursor);
154
+ if (receiverStart === cursor) {
155
+ return null;
156
+ }
157
+ return normalizeReceiverExpression(text.slice(receiverStart, cursor));
158
+ }
159
+
160
+ function readMemberAccessOperator(text, endOffset) {
161
+ if (endOffset >= 2 && text.slice(endOffset - 2, endOffset) === "?.") {
162
+ return { kind: "optional", start: endOffset - 2 };
163
+ }
164
+ if (endOffset >= 1 && text[endOffset - 1] === ".") {
165
+ return { kind: "direct", start: endOffset - 1 };
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function findReceiverStart(text, endOffset) {
171
+ let cursor = endOffset;
172
+ let parenDepth = 0;
173
+ while (cursor > 0) {
174
+ const char = text[cursor - 1];
175
+ if (char === ")") {
176
+ parenDepth += 1;
177
+ cursor -= 1;
178
+ continue;
179
+ }
180
+ if (char === "(") {
181
+ if (parenDepth === 0) {
182
+ break;
183
+ }
184
+ parenDepth -= 1;
185
+ cursor -= 1;
186
+ continue;
187
+ }
188
+ if (parenDepth > 0) {
189
+ cursor -= 1;
190
+ continue;
191
+ }
192
+ if (isReceiverCharacter(char)) {
193
+ cursor -= 1;
194
+ continue;
195
+ }
196
+ break;
197
+ }
198
+ return cursor;
199
+ }
200
+
201
+ function normalizeReceiverExpression(rawReceiver) {
202
+ let normalized = rawReceiver.replace(/\s+/g, "");
203
+ while (normalized.startsWith("(") && normalized.endsWith(")") && wrapsWholeExpression(normalized)) {
204
+ normalized = normalized.slice(1, -1);
205
+ }
206
+ return normalized.replaceAll("?.", ".");
207
+ }
208
+
209
+ function wrapsWholeExpression(value) {
210
+ let depth = 0;
211
+ for (let index = 0; index < value.length; index += 1) {
212
+ const char = value[index];
213
+ if (char === "(") {
214
+ depth += 1;
215
+ } else if (char === ")") {
216
+ depth -= 1;
217
+ if (depth === 0 && index < value.length - 1) {
218
+ return false;
219
+ }
220
+ }
221
+ }
222
+ return depth === 0;
223
+ }
224
+
225
+ function hasCallSuffix(text, startOffset) {
226
+ let cursor = skipWhitespaceForward(text, startOffset);
227
+ if (text.slice(cursor, cursor + 2) === "?.") {
228
+ cursor = skipWhitespaceForward(text, cursor + 2);
229
+ }
230
+ return text[cursor] === "(";
231
+ }
232
+
233
+ function skipWhitespaceForward(text, startOffset) {
234
+ let cursor = startOffset;
235
+ while (cursor < text.length && /\s/.test(text[cursor])) {
236
+ cursor += 1;
237
+ }
238
+ return cursor;
239
+ }
240
+
241
+ function skipWhitespaceBackward(text, endOffset) {
242
+ let cursor = endOffset;
243
+ while (cursor > 0 && /\s/.test(text[cursor - 1])) {
244
+ cursor -= 1;
245
+ }
246
+ return cursor;
247
+ }
248
+
249
+ function isReceiverCharacter(char) {
250
+ return /[A-Za-z0-9_$?.]/.test(char);
251
+ }
252
+
253
+ function isIdentifierBoundary(text, offset) {
254
+ if (offset < 0 || offset >= text.length) {
255
+ return true;
256
+ }
257
+ return !/[A-Za-z0-9_$]/.test(text[offset]);
258
+ }
259
+
260
+ function isRuntimeSessionReceiver(receiver) {
261
+ return /^(?:[A-Za-z_$][A-Za-z0-9_$]*|this)\.runtime\.agent\.session$/.test(receiver);
262
+ }
263
+
264
+ function collectNamespaceUsageDeprecations(findings, context) {
265
+ const regex = /\bimport\s+(?:type\s+)?\*\s+as\s+([A-Za-z_$][\w$]*)\s*from\s*["'`]([^"'`]+)["'`]/g;
266
+ for (const match of context.text.matchAll(regex)) {
267
+ const local = match[1];
268
+ const specifier = match[2];
269
+ if (!loadSessionStoreSpecifiers.has(specifier)) {
270
+ continue;
271
+ }
272
+ collectMemberCallDeprecations(findings, context, {
273
+ receiverMatcher: (receiver) => receiver === local,
274
+ surface: `${specifier} namespace access`,
275
+ });
276
+ }
277
+ }
278
+
279
+ function collectNamespaceRequireDeprecations(findings, context) {
280
+ const regex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
281
+ for (const match of context.text.matchAll(regex)) {
282
+ const local = match[1];
283
+ const specifier = match[2];
284
+ if (!loadSessionStoreSpecifiers.has(specifier)) {
285
+ continue;
286
+ }
287
+ collectMemberCallDeprecations(findings, context, {
288
+ receiverMatcher: (receiver) => receiver === local,
289
+ surface: `${specifier} require namespace access`,
290
+ });
291
+ }
292
+ }
293
+
294
+ function collectRuntimeUsageDeprecations(findings, context) {
295
+ collectMemberCallDeprecations(findings, context, {
296
+ receiverMatcher: isRuntimeSessionReceiver,
297
+ surface: "api.runtime.agent.session",
298
+ });
299
+ }
300
+
301
+ function parseNamedBindings(rawBindings, options = {}) {
302
+ const aliasSeparator = options.aliasSeparator ?? "as";
303
+ return rawBindings
304
+ .split(",")
305
+ .map((binding) => binding.trim())
306
+ .filter(Boolean)
307
+ .map((binding) => binding.replace(/^type\s+/, "").trim())
308
+ .map((binding) => parseBindingAlias(binding, aliasSeparator))
309
+ .filter((binding) => binding.exported && binding.local);
310
+ }
311
+
312
+ function parseBindingAlias(binding, aliasSeparator) {
313
+ if (aliasSeparator === ":") {
314
+ const separatorIndex = binding.indexOf(":");
315
+ if (separatorIndex === -1) {
316
+ return {
317
+ exported: binding.trim(),
318
+ local: binding.trim(),
319
+ };
320
+ }
321
+ return {
322
+ exported: binding.slice(0, separatorIndex).trim(),
323
+ local: binding.slice(separatorIndex + 1).trim(),
324
+ };
325
+ }
326
+
327
+ const tokens = binding.trim().split(/\s+/);
328
+ if (tokens.length === 3 && tokens[1] === "as") {
329
+ return {
330
+ exported: tokens[0],
331
+ local: tokens[2],
332
+ };
333
+ }
334
+
335
+ return {
336
+ exported: binding.trim(),
337
+ local: binding.trim(),
338
+ };
339
+ }
340
+
341
+ function buildFinding(rule, details) {
342
+ const refLine = lineForOffset(details.sourceText, details.offset);
343
+ return {
344
+ code: rule.code,
345
+ surface: details.surface,
346
+ replacement: rule.replacement,
347
+ ref: `${details.filePath}:${refLine}`,
348
+ message: `loadSessionStore keeps the legacy whole-store session shape; use ${rule.replacement}.`,
349
+ offset: details.offset,
350
+ };
351
+ }
352
+
353
+ function uniqueFindings(findings) {
354
+ const byKey = new Map();
355
+ for (const finding of findings) {
356
+ byKey.set(`${finding.code}:${finding.surface}:${finding.ref}`, finding);
357
+ }
358
+ return [...byKey.values()];
359
+ }
360
+
361
+ function lineForOffset(text, offset) {
362
+ let line = 1;
363
+ for (let index = 0; index < offset; index += 1) {
364
+ if (text.charCodeAt(index) === 10) {
365
+ line += 1;
366
+ }
367
+ }
368
+ return line;
369
+ }