@gmickel/gno 1.32.0 → 1.34.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 (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,569 @@
1
+ /** Deterministic read-only knowledge integrity audit runner. */
2
+
3
+ import type {
4
+ AuditCapabilitySnapshot,
5
+ AuditCategory,
6
+ AuditFinding,
7
+ AuditFingerprints,
8
+ AuditForbiddenStoreMethod,
9
+ AuditReport,
10
+ AuditReportStatus,
11
+ AuditRuleContribution,
12
+ AuditRuleContext,
13
+ AuditRuleEvaluator,
14
+ AuditRuleResult,
15
+ AuditRunInput,
16
+ AuditRunResult,
17
+ AuditScope,
18
+ AuditVersions,
19
+ } from "./audit-contract";
20
+
21
+ import { VERSION } from "../app/constants";
22
+ import {
23
+ AUDIT_CATEGORIES,
24
+ AUDIT_DEFAULT_MAX_FINDINGS,
25
+ AUDIT_FORBIDDEN_STORE_METHODS,
26
+ AUDIT_MAX_CODE_CHARS,
27
+ AUDIT_MAX_FINDINGS_LIMIT,
28
+ AUDIT_MAX_IDENTIFIER_CHARS,
29
+ AUDIT_MAX_MESSAGE_CHARS,
30
+ AUDIT_MAX_SCOPE_ITEMS,
31
+ AUDIT_MAX_SCOPE_VALUE_CHARS,
32
+ AUDIT_MAX_SNAPSHOT_ATTEMPTS,
33
+ AUDIT_RULE_SET_VERSION,
34
+ AUDIT_SCHEMA_VERSION,
35
+ } from "./audit-contract";
36
+ import {
37
+ auditCategoryRank,
38
+ boundAuditText,
39
+ compareAuditCodeUnits,
40
+ compareAuditFindings,
41
+ compareAuditRules,
42
+ deriveAuditExitKind,
43
+ deriveAuditReportStatus,
44
+ materializeAuditFinding,
45
+ normalizeAuditText,
46
+ tallyAuditRuleCounts,
47
+ } from "./audit-report";
48
+
49
+ export * from "./audit-contract";
50
+ export * from "./audit-report";
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Scope / input validation
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+
56
+ const isAuditCategory = (value: string): value is AuditCategory =>
57
+ (AUDIT_CATEGORIES as readonly string[]).includes(value);
58
+
59
+ export const normalizeAuditScope = (
60
+ scope: AuditScope
61
+ ): { ok: true; scope: AuditScope } | { ok: false; error: string } => {
62
+ const indexName = normalizeAuditText(scope.indexName);
63
+ if (indexName.length < 1) {
64
+ return { ok: false, error: "indexName is required" };
65
+ }
66
+ if (Array.from(indexName).length > 64) {
67
+ return { ok: false, error: "indexName must be at most 64 characters" };
68
+ }
69
+
70
+ const categories =
71
+ scope.categories.length === 0
72
+ ? [...AUDIT_CATEGORIES]
73
+ : [...new Set(scope.categories.map((item) => normalizeAuditText(item)))];
74
+ for (const category of categories) {
75
+ if (!isAuditCategory(category)) {
76
+ return { ok: false, error: `unknown audit category: ${category}` };
77
+ }
78
+ }
79
+ categories.sort(
80
+ (left, right) =>
81
+ auditCategoryRank(left as AuditCategory) -
82
+ auditCategoryRank(right as AuditCategory)
83
+ );
84
+
85
+ const collections = [
86
+ ...new Set(scope.collections.map((item) => normalizeAuditText(item))),
87
+ ]
88
+ .filter((item) => item.length > 0)
89
+ .sort(compareAuditCodeUnits);
90
+ const paths = [
91
+ ...new Set(scope.paths.map((item) => normalizeAuditText(item))),
92
+ ]
93
+ .filter((item) => item.length > 0)
94
+ .sort(compareAuditCodeUnits);
95
+ const tags = [...new Set(scope.tags.map((item) => normalizeAuditText(item)))]
96
+ .filter((item) => item.length > 0)
97
+ .sort(compareAuditCodeUnits);
98
+
99
+ for (const [name, values, maxChars] of [
100
+ ["collections", collections, AUDIT_MAX_SCOPE_VALUE_CHARS],
101
+ ["paths", paths, AUDIT_MAX_IDENTIFIER_CHARS],
102
+ ["tags", tags, AUDIT_MAX_SCOPE_VALUE_CHARS],
103
+ ] as const) {
104
+ if (values.length > AUDIT_MAX_SCOPE_ITEMS) {
105
+ return {
106
+ ok: false,
107
+ error: `${name} must contain at most ${AUDIT_MAX_SCOPE_ITEMS} values`,
108
+ };
109
+ }
110
+ if (values.some((value) => Array.from(value).length > maxChars)) {
111
+ return {
112
+ ok: false,
113
+ error: `${name} entries must be at most ${maxChars} characters`,
114
+ };
115
+ }
116
+ }
117
+
118
+ return {
119
+ ok: true,
120
+ scope: {
121
+ categories: categories as AuditCategory[],
122
+ collections,
123
+ paths,
124
+ tags,
125
+ indexName,
126
+ },
127
+ };
128
+ };
129
+
130
+ const resolveMaxFindings = (
131
+ value: number | undefined
132
+ ): { ok: true; maxFindings: number } | { ok: false; error: string } => {
133
+ const maxFindings = value ?? AUDIT_DEFAULT_MAX_FINDINGS;
134
+ if (
135
+ !Number.isSafeInteger(maxFindings) ||
136
+ maxFindings < 1 ||
137
+ maxFindings > AUDIT_MAX_FINDINGS_LIMIT
138
+ ) {
139
+ return {
140
+ ok: false,
141
+ error: `maxFindings must be an integer between 1 and ${AUDIT_MAX_FINDINGS_LIMIT}`,
142
+ };
143
+ }
144
+ return { ok: true, maxFindings };
145
+ };
146
+
147
+ const fingerprintsEqual = (
148
+ left: AuditFingerprints,
149
+ right: AuditFingerprints
150
+ ): boolean =>
151
+ left.config === right.config &&
152
+ left.source === right.source &&
153
+ left.index === right.index &&
154
+ left.rules === right.rules;
155
+
156
+ const normalizeFingerprints = (
157
+ fingerprints: AuditFingerprints
158
+ ): AuditFingerprints => ({
159
+ config: normalizeAuditText(fingerprints.config),
160
+ source: normalizeAuditText(fingerprints.source),
161
+ index: normalizeAuditText(fingerprints.index),
162
+ rules: normalizeAuditText(fingerprints.rules),
163
+ });
164
+
165
+ const defaultCapabilities = (
166
+ capabilities: AuditCapabilitySnapshot
167
+ ): AuditCapabilitySnapshot => ({
168
+ ...capabilities,
169
+ offline: true,
170
+ llmDisabled: true,
171
+ });
172
+
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+ // Runner
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+
177
+ const materializeRule = (
178
+ contribution: AuditRuleContribution
179
+ ): AuditRuleResult => {
180
+ const ruleId = boundAuditText(contribution.ruleId, AUDIT_MAX_CODE_CHARS);
181
+ const findings = (contribution.findings ?? [])
182
+ .map((draft) =>
183
+ materializeAuditFinding(ruleId, contribution.category, draft)
184
+ )
185
+ .sort(compareAuditFindings);
186
+
187
+ let status = contribution.status;
188
+ if (findings.length > 0 && status === "pass") {
189
+ status = "fail";
190
+ }
191
+
192
+ return {
193
+ ruleId,
194
+ category: contribution.category,
195
+ status,
196
+ message: boundAuditText(contribution.message, AUDIT_MAX_MESSAGE_CHARS),
197
+ findings,
198
+ findingCount: Math.max(
199
+ findings.length,
200
+ contribution.findingCount ?? findings.length
201
+ ),
202
+ examinedCount: Math.max(0, contribution.examinedCount ?? 0),
203
+ durationMs: Math.max(0, Math.round(contribution.durationMs ?? 0)),
204
+ skipReason:
205
+ contribution.skipReason === undefined || contribution.skipReason === null
206
+ ? null
207
+ : boundAuditText(contribution.skipReason, AUDIT_MAX_MESSAGE_CHARS),
208
+ };
209
+ };
210
+
211
+ const collectContributions = async (
212
+ evaluators: readonly AuditRuleEvaluator[],
213
+ ctx: AuditRuleContext
214
+ ): Promise<AuditRuleContribution[]> => {
215
+ const collected: AuditRuleContribution[] = [];
216
+ for (const evaluator of evaluators) {
217
+ const result = await evaluator(ctx);
218
+ if (Array.isArray(result)) {
219
+ collected.push(...result);
220
+ } else {
221
+ collected.push(result);
222
+ }
223
+ }
224
+ return collected;
225
+ };
226
+
227
+ const buildReportFromRules = (input: {
228
+ scope: AuditScope;
229
+ capabilities: AuditCapabilitySnapshot;
230
+ fingerprints: AuditFingerprints;
231
+ versions: AuditVersions;
232
+ rules: AuditRuleResult[];
233
+ status: AuditReportStatus;
234
+ maxFindings: number;
235
+ startedAt: string;
236
+ completedAt: string;
237
+ snapshotMs: number;
238
+ rulesMs: number;
239
+ totalMs: number;
240
+ }): AuditReport => {
241
+ const materializedRules = [...input.rules]
242
+ .sort(compareAuditRules)
243
+ .map((rule) => ({
244
+ ...rule,
245
+ findings: [...rule.findings].sort(compareAuditFindings),
246
+ }));
247
+
248
+ const allFindings = materializedRules
249
+ .flatMap((rule) => rule.findings)
250
+ .sort(compareAuditFindings);
251
+ // Deduplicate by stable id while preserving canonical order.
252
+ const seen = new Set<string>();
253
+ const uniqueFindings: AuditFinding[] = [];
254
+ for (const finding of allFindings) {
255
+ if (seen.has(finding.id)) continue;
256
+ seen.add(finding.id);
257
+ uniqueFindings.push(finding);
258
+ }
259
+
260
+ const exactFindingCount = materializedRules.reduce(
261
+ (sum, rule) => sum + rule.findingCount,
262
+ 0
263
+ );
264
+ const truncated = exactFindingCount > input.maxFindings;
265
+ const returnedFindings = truncated
266
+ ? uniqueFindings.slice(0, input.maxFindings)
267
+ : uniqueFindings;
268
+ const returnedFindingIds = new Set(
269
+ returnedFindings.map((finding) => finding.id)
270
+ );
271
+ // Rule details and the top-level list share one global payload budget. Exact
272
+ // per-rule totals remain in findingCount, so truncation never hides scale.
273
+ const rules = materializedRules.map((rule) => ({
274
+ ...rule,
275
+ findings: rule.findings.filter((finding) =>
276
+ returnedFindingIds.has(finding.id)
277
+ ),
278
+ }));
279
+
280
+ const examinedDocuments = rules.reduce(
281
+ (sum, rule) => sum + rule.examinedCount,
282
+ 0
283
+ );
284
+
285
+ return {
286
+ schemaVersion: AUDIT_SCHEMA_VERSION,
287
+ ruleSetVersion: AUDIT_RULE_SET_VERSION,
288
+ status: input.status,
289
+ scope: input.scope,
290
+ capabilities: input.capabilities,
291
+ fingerprints: input.fingerprints,
292
+ versions: input.versions,
293
+ startedAt: input.startedAt,
294
+ completedAt: input.completedAt,
295
+ durationMs: Math.max(0, Math.round(input.totalMs)),
296
+ rules,
297
+ findings: returnedFindings,
298
+ counts: {
299
+ rules: tallyAuditRuleCounts(rules),
300
+ findings: {
301
+ total: exactFindingCount,
302
+ returned: returnedFindings.length,
303
+ truncated,
304
+ },
305
+ examined: {
306
+ documents: examinedDocuments,
307
+ },
308
+ },
309
+ truncation: {
310
+ findingsTruncated: truncated,
311
+ maxFindings: input.maxFindings,
312
+ },
313
+ timing: {
314
+ snapshotMs: Math.max(0, Math.round(input.snapshotMs)),
315
+ rulesMs: Math.max(0, Math.round(input.rulesMs)),
316
+ totalMs: Math.max(0, Math.round(input.totalMs)),
317
+ },
318
+ };
319
+ };
320
+
321
+ /**
322
+ * Run the read-only audit runner against injected rule evaluators.
323
+ * Snapshots fingerprints before and after rule evaluation; mid-run changes
324
+ * retry up to `maxAttempts` and otherwise yield `changed_during_audit` —
325
+ * never a clean report.
326
+ */
327
+ export async function runAudit(input: AuditRunInput): Promise<AuditRunResult> {
328
+ const scopeResult = normalizeAuditScope(input.scope);
329
+ if (!scopeResult.ok) {
330
+ return { ok: false, exit: "invalid", error: scopeResult.error };
331
+ }
332
+ const maxFindingsResult = resolveMaxFindings(input.maxFindings);
333
+ if (!maxFindingsResult.ok) {
334
+ return { ok: false, exit: "invalid", error: maxFindingsResult.error };
335
+ }
336
+
337
+ const maxAttempts = Math.max(
338
+ 1,
339
+ Math.min(
340
+ input.maxAttempts ?? AUDIT_MAX_SNAPSHOT_ATTEMPTS,
341
+ AUDIT_MAX_SNAPSHOT_ATTEMPTS
342
+ )
343
+ );
344
+ const clock = input.clock ?? (() => new Date());
345
+ const monotonicNow = input.monotonicNow ?? (() => performance.now());
346
+ const capabilities = defaultCapabilities(input.capabilities);
347
+ const versions: AuditVersions = {
348
+ gno: input.gnoVersion ?? VERSION,
349
+ schema: AUDIT_SCHEMA_VERSION,
350
+ ruleSet: AUDIT_RULE_SET_VERSION,
351
+ };
352
+
353
+ const runStartedAt = clock();
354
+ const runStartedMs = monotonicNow();
355
+ let snapshotMs = 0;
356
+ let rulesMs = 0;
357
+ let lastRules: AuditRuleResult[] = [];
358
+ let lastFingerprints: AuditFingerprints | null = null;
359
+ let snapshotChanged = false;
360
+ let failed = false;
361
+ let failureMessage = "Audit failed";
362
+ const cancellationRule = (): AuditRuleResult =>
363
+ materializeRule({
364
+ ruleId: "audit.cancelled",
365
+ category: scopeResult.scope.categories[0] ?? "links",
366
+ status: "inconclusive",
367
+ message: "Audit was cancelled",
368
+ findings: [],
369
+ findingCount: 0,
370
+ skipReason: "cancelled",
371
+ });
372
+
373
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
374
+ if (input.signal?.aborted) {
375
+ lastRules = [cancellationRule()];
376
+ break;
377
+ }
378
+ const snapshotStarted = monotonicNow();
379
+ let before: AuditFingerprints;
380
+ try {
381
+ before = normalizeFingerprints(await input.captureFingerprints());
382
+ } catch (cause) {
383
+ if (input.signal?.aborted) {
384
+ lastRules = [cancellationRule()];
385
+ break;
386
+ }
387
+ failed = true;
388
+ failureMessage =
389
+ cause instanceof Error
390
+ ? cause.message
391
+ : "Failed to capture audit fingerprints";
392
+ break;
393
+ }
394
+ snapshotMs += Math.max(0, monotonicNow() - snapshotStarted);
395
+ lastFingerprints = before;
396
+
397
+ const ruleCtx: AuditRuleContext = {
398
+ scope: scopeResult.scope,
399
+ capabilities,
400
+ fingerprints: before,
401
+ attempt,
402
+ };
403
+
404
+ const rulesStarted = monotonicNow();
405
+ try {
406
+ const contributions = await collectContributions(input.rules, ruleCtx);
407
+ lastRules = contributions.map(materializeRule);
408
+ } catch (cause) {
409
+ if (input.signal?.aborted) {
410
+ lastRules = [cancellationRule()];
411
+ break;
412
+ }
413
+ failed = true;
414
+ failureMessage =
415
+ cause instanceof Error ? cause.message : "Audit rule evaluation failed";
416
+ break;
417
+ }
418
+ rulesMs += Math.max(0, monotonicNow() - rulesStarted);
419
+
420
+ if (input.signal?.aborted) {
421
+ lastRules = [cancellationRule()];
422
+ break;
423
+ }
424
+
425
+ const afterStarted = monotonicNow();
426
+ let after: AuditFingerprints;
427
+ try {
428
+ after = normalizeFingerprints(await input.captureFingerprints());
429
+ } catch (cause) {
430
+ if (input.signal?.aborted) {
431
+ lastRules = [cancellationRule()];
432
+ break;
433
+ }
434
+ failed = true;
435
+ failureMessage =
436
+ cause instanceof Error
437
+ ? cause.message
438
+ : "Failed to re-capture audit fingerprints";
439
+ break;
440
+ }
441
+ snapshotMs += Math.max(0, monotonicNow() - afterStarted);
442
+
443
+ if (fingerprintsEqual(before, after)) {
444
+ snapshotChanged = false;
445
+ lastFingerprints = after;
446
+ break;
447
+ }
448
+
449
+ snapshotChanged = true;
450
+ lastFingerprints = after;
451
+ if (attempt === maxAttempts) {
452
+ break;
453
+ }
454
+ // Discard rule results from a drifted attempt; retry with a fresh snapshot.
455
+ lastRules = [];
456
+ }
457
+
458
+ const completedAt = clock();
459
+ const totalMs = Math.max(0, monotonicNow() - runStartedMs);
460
+ const fingerprints = lastFingerprints ?? {
461
+ config: "",
462
+ source: "",
463
+ index: "",
464
+ rules: "",
465
+ };
466
+
467
+ if (failed) {
468
+ const report = buildReportFromRules({
469
+ scope: scopeResult.scope,
470
+ capabilities,
471
+ fingerprints,
472
+ versions,
473
+ rules: [
474
+ {
475
+ ruleId: "audit.runner",
476
+ category: scopeResult.scope.categories[0] ?? "links",
477
+ status: "unavailable",
478
+ message: failureMessage,
479
+ findings: [],
480
+ findingCount: 0,
481
+ examinedCount: 0,
482
+ durationMs: 0,
483
+ skipReason: failureMessage,
484
+ },
485
+ ],
486
+ status: "failed",
487
+ maxFindings: maxFindingsResult.maxFindings,
488
+ startedAt: runStartedAt.toISOString(),
489
+ completedAt: completedAt.toISOString(),
490
+ snapshotMs,
491
+ rulesMs,
492
+ totalMs,
493
+ });
494
+ return { ok: true, report, exit: deriveAuditExitKind(report) };
495
+ }
496
+
497
+ const status = deriveAuditReportStatus({
498
+ rules: lastRules,
499
+ snapshotChanged,
500
+ });
501
+
502
+ // Mid-run drift must never report clean, even if evaluators emitted no findings.
503
+ const rulesForReport =
504
+ status === "changed_during_audit" && lastRules.length === 0
505
+ ? [
506
+ {
507
+ ruleId: "audit.snapshot",
508
+ category: scopeResult.scope.categories[0] ?? "links",
509
+ status: "inconclusive" as const,
510
+ message:
511
+ "Source or index fingerprints changed during audit; results are not authoritative",
512
+ findings: [] as AuditFinding[],
513
+ findingCount: 0,
514
+ examinedCount: 0,
515
+ durationMs: 0,
516
+ skipReason: "changed_during_audit",
517
+ },
518
+ ]
519
+ : lastRules;
520
+
521
+ const report = buildReportFromRules({
522
+ scope: scopeResult.scope,
523
+ capabilities,
524
+ fingerprints,
525
+ versions,
526
+ rules: rulesForReport,
527
+ status:
528
+ status === "changed_during_audit"
529
+ ? "changed_during_audit"
530
+ : deriveAuditReportStatus({
531
+ rules: rulesForReport,
532
+ snapshotChanged: false,
533
+ }),
534
+ maxFindings: maxFindingsResult.maxFindings,
535
+ startedAt: runStartedAt.toISOString(),
536
+ completedAt: completedAt.toISOString(),
537
+ snapshotMs,
538
+ rulesMs,
539
+ totalMs,
540
+ });
541
+
542
+ return { ok: true, report, exit: deriveAuditExitKind(report) };
543
+ }
544
+
545
+ /**
546
+ * Assert a store-like object does not expose callable mutating methods used
547
+ * by audits. Tests wrap ports to prove no writes occur during a run.
548
+ */
549
+ export function createAuditWriteGuard<T extends object>(
550
+ port: T,
551
+ forbidden: readonly AuditForbiddenStoreMethod[] = AUDIT_FORBIDDEN_STORE_METHODS
552
+ ): T & { readonly writeAttempts: readonly string[] } {
553
+ const writeAttempts: string[] = [];
554
+ const forbiddenMethods = new Set<string>(forbidden);
555
+ return new Proxy(port, {
556
+ get(target, property, receiver) {
557
+ if (property === "writeAttempts") return writeAttempts;
558
+ if (typeof property === "string" && forbiddenMethods.has(property)) {
559
+ return (..._args: unknown[]) => {
560
+ writeAttempts.push(property);
561
+ throw new Error(
562
+ `Audit must not call mutating store method ${property}`
563
+ );
564
+ };
565
+ }
566
+ return Reflect.get(target, property, receiver);
567
+ },
568
+ }) as T & { readonly writeAttempts: readonly string[] };
569
+ }