@datagrok/proteomics 1.0.1 → 1.2.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 (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,515 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+ import {awaitCheck, delay} from '@datagrok-libraries/test/src/test';
4
+
5
+ import {
6
+ META_COLUMNS, PUBLISHED_TAGS, PublishOptions, PublishedMetadata,
7
+ slugifyTarget,
8
+ } from './publish-state';
9
+ import {trimEnrichmentForPublish, trimForPublish} from './trim-dataframe';
10
+ import {
11
+ FORMULA_LINE_ASSERTION_PREFIX, PublishedShapeContract, assertPublishedShape,
12
+ } from './assert-published-shape';
13
+ import {createVolcanoPlot} from '../viewers/volcano';
14
+ import {dockEnrichmentCharts, openEnrichmentVisualization} from '../viewers/enrichment-viewers';
15
+ import {reviewSpaceName, reviewNamePrefix, verifyPublishedDashboard} from './publish-settings';
16
+ import {DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
17
+
18
+ /**
19
+ * Pure helper: applies (or replaces) the volcano threshold formula lines on
20
+ * the DataFrame attached to {@link viewer}. Writes to `df.meta.formulaLines`
21
+ * since that is where `applyThresholdLines` in `src/viewers/volcano.ts` stores
22
+ * them (the platform's canonical location for scatter-plot formula lines).
23
+ *
24
+ * Exported so Plan 07's post-open recovery hook can re-apply the lines on a
25
+ * reopened published Project's volcano when the serializer strips look config
26
+ * (Phase 13 e527d07ba1 evidence). Idempotent — removes existing FC/y-axis
27
+ * threshold lines before re-adding so repeat calls do not stack.
28
+ */
29
+ export function applyVolcanoFormulaLines(
30
+ viewer: DG.Viewer,
31
+ fcThreshold: number,
32
+ pThreshold: number,
33
+ ): void {
34
+ const df = (viewer as any).dataFrame as DG.DataFrame | undefined;
35
+ if (!df) return;
36
+
37
+ let yColName: string | null = null;
38
+ try {
39
+ const opts: any = (viewer as any).getOptions?.();
40
+ yColName = opts?.look?.yColumnName ?? null;
41
+ } catch { /* fall through */ }
42
+ if (!yColName) {
43
+ try { yColName = (viewer as any).props?.yColumnName ?? null; } catch { /* fall through */ }
44
+ }
45
+ if (!yColName) return;
46
+
47
+ const yPrefix = `\${${yColName}}`;
48
+ const fcPrefix = '${log2FC}';
49
+ const hLine = -Math.log10(pThreshold);
50
+
51
+ const meta: any = (df as any).meta;
52
+ const formulaLines: any = meta?.formulaLines;
53
+ if (!formulaLines) return;
54
+
55
+ try {
56
+ const items: any[] = Array.isArray(formulaLines.items) ? formulaLines.items : [];
57
+ formulaLines.items = items.filter((line: any) => {
58
+ const f = line?.formula ?? '';
59
+ return !(typeof f === 'string' && (f.startsWith(yPrefix) || f.startsWith(fcPrefix)));
60
+ });
61
+ } catch { /* best effort */ }
62
+
63
+ try {
64
+ formulaLines.addLine({formula: `${yPrefix} = ${hLine}`, color: '#888888', width: 1, visible: true});
65
+ formulaLines.addLine({formula: `${fcPrefix} = ${fcThreshold}`, color: '#888888', width: 1, visible: true});
66
+ formulaLines.addLine({formula: `${fcPrefix} = ${-fcThreshold}`, color: '#888888', width: 1, visible: true});
67
+ } catch { /* best effort */ }
68
+
69
+ try { (viewer as any).props.showViewerFormulaLines = true; } catch { /* best effort */ }
70
+ }
71
+
72
+ function parsePriorVersion(priorName: string | null | undefined): number {
73
+ if (!priorName) return 0;
74
+ const m = /-v(\d+)-/.exec(priorName);
75
+ if (!m) return 0;
76
+ const n = parseInt(m[1], 10);
77
+ return Number.isFinite(n) ? n : 0;
78
+ }
79
+
80
+ function generateUuid(): string {
81
+ try {
82
+ const c = (globalThis as any)?.crypto;
83
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
84
+ } catch { /* fall through */ }
85
+ // RFC4122 v4 fallback
86
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
87
+ const r = (Math.random() * 16) | 0;
88
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
89
+ return v.toString(16);
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Orchestrates the read-only publish flow. Source DataFrame is NEVER mutated
95
+ * (Pitfall 1 / T-15-02 guarantee — Plan 02's `trimForPublish` owns the clone
96
+ * boundary). Two non-negotiable gates: Step 7 verifies reviewer-group ACL via
97
+ * `permissions.get` and rolls back on Edit-leak; Step 8 calls
98
+ * {@link assertPublishedShape} against the reopened Project and rolls back on
99
+ * contract violation — with a one-shot W-7 self-heal pass for the
100
+ * formula-line-stripped failure mode.
101
+ *
102
+ * Publish-side belt-and-braces for PUB-06 / SC-2 formula lines:
103
+ * #1 (this orchestrator, step 6.5) — apply formula lines on the trimmed
104
+ * volcano BEFORE saving the view
105
+ * #2 (Plan 01 setPublishedTags via Plan 02 trimForPublish) — persist FC and
106
+ * p threshold values as `proteomics.published_fc_threshold` /
107
+ * `proteomics.published_p_threshold` tags on the trimmed DataFrame so
108
+ * the recovery hook can re-apply them later
109
+ * #3 (Plan 07 post-open recovery hook) — re-apply formula lines from the
110
+ * tags on reopen if the serializer stripped them; reuses the exported
111
+ * {@link applyVolcanoFormulaLines} helper
112
+ *
113
+ * Per spike 15-00 (`15-00-SUMMARY.md`):
114
+ * - A1: `permissions.get` returns `["view", "edit"]` keys only (no share/delete)
115
+ * - A2: Space-inheritance grants do NOT propagate to `permissions.get(project)`
116
+ * → Step 7 grants View directly on the Project, not via the parent Space
117
+ * - A4: `project.options[*]` survives round-trip → supersede pointer uses options
118
+ */
119
+ export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): Promise<DG.Project> {
120
+ const pi = DG.TaskBarProgressIndicator.create('Publishing snapshot...');
121
+ try {
122
+ // ─── Step 1 — assemble PublishedMetadata (server-authoritative identity) ────
123
+ pi.description = 'Preparing metadata...';
124
+ const publishedBy = (grok.shell.user as any)?.friendlyName ?? '';
125
+ const publishedByEmail = (grok.shell.user as any)?.email ?? null;
126
+ const deMethod = (df.getTag('proteomics.de_method') ?? 't-test') as PublishedMetadata['deMethod'];
127
+
128
+ let fcThreshold = DEFAULT_FC_THRESHOLD;
129
+ let pThreshold = DEFAULT_P_THRESHOLD;
130
+ try {
131
+ const optsAny = opts as any;
132
+ if (typeof optsAny.fcThreshold === 'number' && Number.isFinite(optsAny.fcThreshold))
133
+ fcThreshold = optsAny.fcThreshold;
134
+ if (typeof optsAny.pThreshold === 'number' && Number.isFinite(optsAny.pThreshold))
135
+ pThreshold = optsAny.pThreshold;
136
+ } catch { /* defaults */ }
137
+
138
+ const slug = slugifyTarget(opts.target);
139
+ const priorVersionN = parsePriorVersion((opts.priorVersion as any)?.name);
140
+ const version = priorVersionN > 0 ? priorVersionN + 1 : 1;
141
+ const publishId = generateUuid();
142
+ const publishedAt = new Date();
143
+ const dateStr = publishedAt.toISOString().slice(0, 10);
144
+
145
+ const enrichSource: DG.DataFrame | null = (() => {
146
+ const tables = (grok.shell as any).tables as DG.DataFrame[] | undefined;
147
+ if (!Array.isArray(tables)) return null;
148
+ return tables.find((t) => t.getTag('proteomics.enrichment') === 'true') ?? null;
149
+ })();
150
+
151
+ const meta: PublishedMetadata = {
152
+ target: opts.target,
153
+ publishedAt,
154
+ publishedBy,
155
+ publishedByEmail,
156
+ deMethod,
157
+ fcThreshold,
158
+ pThreshold,
159
+ version,
160
+ publishId,
161
+ includesEnrichment: !!enrichSource,
162
+ supersedes: opts.priorVersion?.id ?? null,
163
+ supersededBy: null,
164
+ };
165
+
166
+ // ─── Step 2 — trim protein DF (Pitfall 1: source not mutated) ────────────────
167
+ pi.description = 'Trimming snapshot...';
168
+ const frozen = trimForPublish(df, meta);
169
+ const expectedName = frozen.name;
170
+
171
+ // ─── Step 3 — opportunistic enrichment trim (D-05) ──────────────────────────
172
+ let frozenEnrich: DG.DataFrame | null = null;
173
+ if (enrichSource) {
174
+ pi.description = 'Trimming enrichment...';
175
+ frozenEnrich = trimEnrichmentForPublish(enrichSource, meta);
176
+ }
177
+
178
+ // ─── Step 4 — ensure umbrella Space (opts.umbrellaName override supported) ──
179
+ pi.description = 'Ensuring umbrella Space...';
180
+ const umbrellaName = opts.umbrellaName ?? reviewSpaceName();
181
+ const dapiAny = grok.dapi as any;
182
+ let umbrella: any = null;
183
+
184
+ // Try create-first; if the Space already exists, fall back to enumeration.
185
+ // The Datagrok smart-filter API does not reliably surface Space-flagged
186
+ // Projects via `dapi.projects.filter(... and isSpace = true).first()`,
187
+ // so we rely on `spaces.list()` enumeration as the lookup path.
188
+ try {
189
+ umbrella = await dapiAny.spaces.createRootSpace(umbrellaName);
190
+ } catch (createErr: any) {
191
+ const msg = String(createErr?.message ?? createErr ?? '');
192
+ if (!/already exists|exists/i.test(msg)) throw createErr;
193
+ try {
194
+ const all: any[] = await dapiAny.spaces.list();
195
+ umbrella = (all ?? []).find((p) =>
196
+ p?.name === umbrellaName || (p as any)?.friendlyName === umbrellaName) ?? null;
197
+ } catch { /* fall through */ }
198
+ if (!umbrella) {
199
+ throw new Error(
200
+ `Umbrella Space '${umbrellaName}' exists on the server but could not be resolved ` +
201
+ `by name from spaces.list() (msg: ${msg}). Ask an admin to inspect the conflicting Space.`);
202
+ }
203
+ }
204
+ const umbrellaClient = dapiAny.spaces.id(umbrella.id);
205
+
206
+ // ─── Step 5 — ensure per-target child Space ─────────────────────────────────
207
+ pi.description = 'Ensuring per-target Space...';
208
+ const childName = `${reviewNamePrefix()}-${slug}`;
209
+ let childSpace: any = null;
210
+
211
+ const findChildByName = async (): Promise<any> => {
212
+ try {
213
+ const kids: any[] = await umbrellaClient.children.filter('Project', false).list();
214
+ return (kids ?? []).find((k) =>
215
+ k?.friendlyName === childName || k?.name === childName) ?? null;
216
+ } catch { return null; }
217
+ };
218
+
219
+ try {
220
+ if (await umbrellaClient.subspaceExists(childName)) {
221
+ childSpace = await findChildByName();
222
+ }
223
+ } catch { /* fall through to addSubspace */ }
224
+
225
+ if (!childSpace) {
226
+ try {
227
+ childSpace = await umbrellaClient.addSubspace(childName);
228
+ } catch (subErr: any) {
229
+ const msg = String(subErr?.message ?? subErr ?? '');
230
+ if (!/already exists|exists/i.test(msg)) throw subErr;
231
+ childSpace = await findChildByName();
232
+ if (!childSpace) {
233
+ throw new Error(
234
+ `Per-target child Space '${childName}' exists but could not be resolved via ` +
235
+ `umbrella children.list() (msg: ${msg}).`);
236
+ }
237
+ }
238
+ }
239
+ const childClient = dapiAny.spaces.id(childSpace.id);
240
+
241
+ // ─── Step 6 — create Project + addChild ─────────────────────────────────────
242
+ pi.description = 'Saving Project...';
243
+ const project = DG.Project.create();
244
+ const projectName = `${reviewNamePrefix()}-${slug}-v${version}-${dateStr}`;
245
+ (project as any).name = projectName;
246
+ // Set friendlyName explicitly so the platform doesn't "humanize" the slug
247
+ // (e.g. DMD → "DM D"). Use the original target verbatim for a clean label,
248
+ // and derive the label's leading words from the (configurable) name prefix.
249
+ (project as any).friendlyName =
250
+ `${reviewNamePrefix().replace(/-/g, ' ')} ${opts.target} v${version} ${dateStr}`;
251
+
252
+ try { (project as any).options[PUBLISHED_TAGS.PUBLISHED_ID] = publishId; } catch { /* swallow */ }
253
+ // Step 9 writes the supersede pointer on the prior project; the NEW
254
+ // project's `supersedes` pointer must be written BEFORE save so the
255
+ // round-trip assertion in Step 8 sees it on reopen.
256
+ if (opts.priorVersion != null) {
257
+ try {
258
+ (project as any).options[PUBLISHED_TAGS.SUPERSEDES] = (opts.priorVersion as any).id;
259
+ } catch { /* swallow */ }
260
+ }
261
+
262
+ project.addChild(frozen.getTableInfo());
263
+ if (frozenEnrich) project.addChild(frozenEnrich.getTableInfo());
264
+
265
+ // ─── Step 6.5 — REVISION B-2: re-create volcano on trimmed DF + apply lines ─
266
+ pi.description = 'Re-rendering volcano on trimmed snapshot...';
267
+ const trimmedTv = grok.shell.addTableView(frozen);
268
+ await delay(100);
269
+ const volcano = createVolcanoPlot(frozen, {fcThreshold, pThreshold});
270
+ trimmedTv.addViewer(volcano);
271
+ applyVolcanoFormulaLines(volcano, fcThreshold, pThreshold);
272
+ await delay(100);
273
+
274
+ const viewInfo = trimmedTv.getInfo();
275
+ project.addChild(viewInfo);
276
+
277
+ // ─── Step 6.6 — re-create the enrichment Up/Down 2×2 on the published
278
+ // enrichment view. The charts bind to frozenEnrich itself (dockEnrichmentCharts
279
+ // adds the derived negLog10FDR / ~enrichChartTop columns onto it and splits
280
+ // Up/Down via per-viewer formula filters), so there are no chart-backing
281
+ // subset tables to bundle — the enrichment frame is fully self-contained and
282
+ // the charts survive the project round-trip. ─────────────────────────────
283
+ let enrichViewInfo: any = null;
284
+ if (frozenEnrich) {
285
+ pi.description = 'Re-rendering enrichment charts...';
286
+ const enrichTv = grok.shell.addTableView(frozenEnrich);
287
+ await delay(100);
288
+ dockEnrichmentCharts(enrichTv, frozenEnrich);
289
+ await delay(100);
290
+ enrichViewInfo = enrichTv.getInfo();
291
+ project.addChild(enrichViewInfo);
292
+ // Land the user (and downstream readers of grok.shell.tv) back on the
293
+ // protein/volcano view — the primary deliverable — not the enrichment tab.
294
+ grok.shell.v = trimmedTv;
295
+ }
296
+
297
+ await grok.dapi.tables.uploadDataFrame(frozen);
298
+ await grok.dapi.tables.save(frozen.getTableInfo());
299
+ if (frozenEnrich) {
300
+ // Upload AFTER dockEnrichmentCharts so the derived chart columns persist.
301
+ await grok.dapi.tables.uploadDataFrame(frozenEnrich);
302
+ await grok.dapi.tables.save(frozenEnrich.getTableInfo());
303
+ }
304
+ await grok.dapi.views.save(viewInfo);
305
+ if (enrichViewInfo) await grok.dapi.views.save(enrichViewInfo);
306
+ await grok.dapi.projects.save(project);
307
+ // meta.publishId stays at the originally-generated UUID — it identifies the
308
+ // *publish event* in the DataFrame's tag/column, distinct from project.id
309
+ // (the Datagrok entity id). Step 9 supersede pointers use project.id directly.
310
+
311
+ try { await childClient.addEntity(project.id); } catch (mvErr: any) {
312
+ // Project move to child Space failed — surface but do not abort, since
313
+ // the verify-and-rollback gate below grants directly on the Project
314
+ // (not via Space inheritance per spike A2).
315
+ grok.shell.warning(
316
+ `Could not move Project into child Space "${childName}": ${mvErr?.message ?? mvErr}. ` +
317
+ `Continuing — reviewer view grant is applied directly to the Project.`);
318
+ }
319
+
320
+ // ─── Step 7a — grant View directly on the Project (spike A2 — inheritance not visible) ──
321
+ pi.description = 'Granting reviewer view-only access...';
322
+ try {
323
+ await grok.dapi.permissions.grant(project, opts.reviewerGroup, false);
324
+ } catch (grantErr: any) {
325
+ try { await grok.dapi.projects.delete(project); } catch (rb: any) {
326
+ grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
327
+ }
328
+ throw new Error(`Reviewer group view grant failed: ${grantErr?.message ?? grantErr}`);
329
+ }
330
+
331
+ // ─── Step 7b — verify-and-rollback gate (T-15-01, NON-NEGOTIABLE) ───────────
332
+ // Spike A2: `permissions.get(project)` does NOT surface Space-inherited grants.
333
+ // So we check three rings: project, child Space, umbrella Space. Any Edit/Share/
334
+ // Delete on the reviewer group at any level is a leak that fails the gate.
335
+ pi.description = 'Verifying view-only access...';
336
+ const reviewerId = (opts.reviewerGroup as any)?.id;
337
+ const matchesGroup = (g: any): boolean => {
338
+ if (g == null) return false;
339
+ if (typeof g === 'string') return g === reviewerId;
340
+ if (g?.id != null) return g.id === reviewerId;
341
+ return false;
342
+ };
343
+
344
+ const projPerm: any = await grok.dapi.permissions.get(project);
345
+ let childSpacePerm: any = null;
346
+ let umbrellaPerm: any = null;
347
+ try { if (childSpace) childSpacePerm = await grok.dapi.permissions.get(childSpace); } catch { /* swallow */ }
348
+ try { if (umbrella) umbrellaPerm = await grok.dapi.permissions.get(umbrella); } catch { /* swallow */ }
349
+
350
+ const inAny = (key: string): boolean => {
351
+ for (const perm of [projPerm, childSpacePerm, umbrellaPerm]) {
352
+ const arr = (perm as any)?.[key];
353
+ if (Array.isArray(arr) && arr.some(matchesGroup)) return true;
354
+ }
355
+ return false;
356
+ };
357
+
358
+ const inView = inAny('view');
359
+ const inEdit = inAny('edit');
360
+ const inShare = inAny('share');
361
+ const inDelete = inAny('delete');
362
+
363
+ if (!inView || inEdit || inShare || inDelete) {
364
+ try { await grok.dapi.projects.delete(project); } catch (rb: any) {
365
+ grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
366
+ }
367
+ throw new Error(
368
+ 'Reviewer group already has elevated access via Space inheritance — ' +
369
+ 'publish aborted; ask an admin to scope the umbrella Space\'s permissions');
370
+ }
371
+
372
+ // ─── Step 8 — round-trip verification (optional; `verifyPublishedDashboard`,
373
+ // default ON) with W-7 self-heal on formula lines. This is the heavy part:
374
+ // closeAll + reopen the project + assert it survives a reload. Keep it on for
375
+ // client deliverables; turn it off for faster demo shares. The reviewer-access
376
+ // verify-and-rollback gate (Step 7b) is separate and always runs. ────────────
377
+ // Per-share checkbox (opts.verify) wins; fall back to the package setting for
378
+ // non-dialog callers.
379
+ if (opts.verify ?? verifyPublishedDashboard()) {
380
+ pi.description = 'Verifying round-trip survival...';
381
+ // expectedAllowlist captures the actual post-trim post-volcano column state
382
+ // (volcano factory in step 6.5 adds derived columns like '-log10(adj.p-value)'),
383
+ // not just the trim allowlist. The trim allowlist is the floor; the orchestrator
384
+ // may add derived columns; the contract reflects what actually ships.
385
+ // Exclude _meta_* belt-and-braces columns AND ~-prefixed technical columns
386
+ // (e.g. the volcano's '~Volcano label') — assertPublishedShape ignores both
387
+ // on the reopened side, so the expected list must too or the count mismatches.
388
+ const expectedAllowlist = frozen.columns.toList()
389
+ .map((c) => c.name)
390
+ .filter((n) => !n.startsWith('_meta_') && !n.startsWith('~'));
391
+ const contract: PublishedShapeContract = {
392
+ expectedName,
393
+ expectedProjectName: projectName,
394
+ expectedAllowlist,
395
+ expectedMeta: {...meta},
396
+ expectVolcano: true,
397
+ expectEnrichment: !!frozenEnrich,
398
+ expectFormulaLines: true,
399
+ };
400
+
401
+ let healedOnce = false;
402
+ while (true) {
403
+ try {
404
+ await assertPublishedShape(project, contract);
405
+ break;
406
+ } catch (assertErr: any) {
407
+ const msg = String(assertErr?.message ?? assertErr ?? '');
408
+ if (!healedOnce && msg.startsWith(FORMULA_LINE_ASSERTION_PREFIX)) {
409
+ healedOnce = true;
410
+ const tv = grok.shell.tv;
411
+ const reopenedVolcano = tv
412
+ ? Array.from(tv.viewers).find((v) => v.type === DG.VIEWER.SCATTER_PLOT)
413
+ : null;
414
+ if (reopenedVolcano && tv) {
415
+ const reDf = tv.dataFrame;
416
+ const fcFromTag = parseFloat(reDf.getTag(PUBLISHED_TAGS.PUBLISHED_FC_THRESHOLD) ?? String(fcThreshold));
417
+ const pFromTag = parseFloat(reDf.getTag(PUBLISHED_TAGS.PUBLISHED_P_THRESHOLD) ?? String(pThreshold));
418
+ applyVolcanoFormulaLines(
419
+ reopenedVolcano,
420
+ Number.isFinite(fcFromTag) ? fcFromTag : fcThreshold,
421
+ Number.isFinite(pFromTag) ? pFromTag : pThreshold,
422
+ );
423
+ try { await grok.dapi.views.save(tv.getInfo()); } catch { /* best-effort */ }
424
+ await delay(100);
425
+ continue;
426
+ }
427
+ }
428
+ try { await grok.dapi.projects.delete(project); } catch (rb: any) {
429
+ grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
430
+ }
431
+ throw new Error(`Round-trip shape verification failed: ${assertErr?.message ?? assertErr}`);
432
+ }
433
+ }
434
+ }
435
+
436
+ // ─── Step 9 — supersede chain (D-04 + W-8 dual-write, non-destructive) ──────
437
+ if (opts.priorVersion != null) {
438
+ pi.description = 'Updating supersede chain...';
439
+ const priorProjectId = (opts.priorVersion as any).id;
440
+ try {
441
+ const priorReloaded = await grok.dapi.projects.find(priorProjectId);
442
+ (priorReloaded as any).options[PUBLISHED_TAGS.SUPERSEDED_BY] = project.id;
443
+ await grok.dapi.projects.save(priorReloaded);
444
+ } catch (priorOptsErr: any) {
445
+ grok.shell.warning(
446
+ `Supersede pointer on prior version's options failed: ${priorOptsErr?.message ?? priorOptsErr} ` +
447
+ `(DataFrame-tag dual-write path below provides fallback).`);
448
+ }
449
+
450
+ try {
451
+ grok.shell.closeAll();
452
+ await delay(100);
453
+ const priorReopened = await grok.dapi.projects.find(priorProjectId);
454
+ await priorReopened.open();
455
+ await awaitCheck(
456
+ () => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
457
+ 'supersede stamp: prior project DF did not materialize',
458
+ 5000,
459
+ );
460
+ const priorDf = grok.shell.tv!.dataFrame;
461
+ priorDf.setTag(PUBLISHED_TAGS.SUPERSEDED_BY, project.id);
462
+ const supersededByCol = priorDf.col(META_COLUMNS.SUPERSEDED_BY);
463
+ if (supersededByCol) {
464
+ try { supersededByCol.set(0, project.id); } catch { /* best-effort */ }
465
+ }
466
+ await grok.dapi.tables.uploadDataFrame(priorDf);
467
+ await grok.dapi.tables.save(priorDf.getTableInfo());
468
+ } catch (stampErr: any) {
469
+ grok.shell.warning(
470
+ `Supersede tag could not be stamped on prior version DF: ${stampErr?.message ?? stampErr} ` +
471
+ `(Project.options pointer was still written — Plan 06 panel falls back to that path)`);
472
+ }
473
+
474
+ try { (project as any).options[PUBLISHED_TAGS.SUPERSEDES] = priorProjectId; } catch { /* swallow */ }
475
+ try { await grok.dapi.projects.save(project); } catch (e) {
476
+ grok.shell.warning(`Could not write supersedes pointer on new project options: ${(e as Error)?.message ?? e}`);
477
+ }
478
+ }
479
+
480
+ // ─── Step 10 — restore the user's workspace ─────────────────────────────────
481
+ // Round-trip verification (assertPublishedShape) does closeAll() + reopens the
482
+ // published project to prove it survives a reload, and a self-heal step re-saves
483
+ // a viewer (leaving that reopened project dirty → a SAVE badge). The supersede
484
+ // path likewise closes everything and opens the PRIOR version. Either way the
485
+ // user's original analysis is gone and the workspace is cluttered with
486
+ // verification artifacts. Tear all that down and put them back on their source
487
+ // analysis (table + volcano, plus the enrichment view + charts they had open);
488
+ // the shared copy is one click away in the success dialog's "Open shared analysis".
489
+ pi.description = 'Restoring your analysis...';
490
+ try {
491
+ grok.shell.closeAll();
492
+ await delay(100);
493
+ // Restore the enrichment results view + charts (and its volcano cross-link)
494
+ // FIRST if the user had it open. openEnrichmentVisualization focuses the
495
+ // enrichment tab; adding the protein view next lands the user back on the
496
+ // volcano — the primary deliverable — with enrichment one tab away, exactly
497
+ // as it was pre-publish. Uses enrichSource (the original untrimmed table),
498
+ // not the trimmed publish copy.
499
+ if (enrichSource) {
500
+ try { openEnrichmentVisualization(enrichSource, df); } catch { /* best-effort */ }
501
+ }
502
+ const restoredTv = grok.shell.addTableView(df);
503
+ try {
504
+ restoredTv.addViewer(createVolcanoPlot(df));
505
+ } catch { /* volcano is best-effort — the table view is what matters */ }
506
+ } catch { /* restore is best-effort — never fail a successful publish on cleanup */ }
507
+
508
+ pi.description = 'Done.';
509
+ // No toast here — the share-dialog caller emits the single user-facing
510
+ // confirmation, so publishing stays quiet to avoid a duplicate.
511
+ return project;
512
+ } finally {
513
+ pi.close();
514
+ }
515
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Configurable destination for shared review snapshots.
3
+ *
4
+ * Both values are package settings (declared in `package.json` under `properties`, edited
5
+ * from the package's **Settings** panel in the platform). They are read at share time, so
6
+ * an admin can retarget where reviews land without a code change.
7
+ *
8
+ * What is and isn't configurable, and why:
9
+ * - The **umbrella Space** ({@link reviewSpaceName}) and the **name prefix**
10
+ * ({@link reviewNamePrefix}) are free text.
11
+ * - The structural suffix `-<target-slug>-v<version>-<date>` is NOT configurable: the
12
+ * republish flow finds the prior version by matching `"<prefix>-<slug>-v"` and parsing
13
+ * the `-v<n>-` segment (see `findPriorShare` in `publish-state.ts`). Letting the suffix
14
+ * vary would break version detection. So we expose the prefix, keep the skeleton fixed.
15
+ *
16
+ * Note: changing the prefix after shares already exist orphans the old-prefix versions from
17
+ * republish detection (a fresh share starts at v1 again). That's the expected cost of
18
+ * retargeting; the defaults reproduce the original hard-coded behaviour.
19
+ */
20
+ import {_package} from '../package';
21
+
22
+ export const DEFAULT_REVIEW_SPACE = 'Proteomics-Reviews';
23
+ export const DEFAULT_REVIEW_NAME_PREFIX = 'Proteomics-Review';
24
+
25
+ /** Reads a string package setting, falling back when unset/blank/not-yet-loaded. */
26
+ function readSetting(key: string, fallback: string): string {
27
+ try {
28
+ const v = (_package?.settings as Record<string, unknown> | undefined)?.[key];
29
+ if (typeof v === 'string' && v.trim().length > 0)
30
+ return v.trim();
31
+ } catch { /* settings not ready — use fallback */ }
32
+ return fallback;
33
+ }
34
+
35
+ /** Umbrella Space the per-target review Spaces live under. Setting: `reviewSpaceName`. */
36
+ export function reviewSpaceName(): string {
37
+ return readSetting('reviewSpaceName', DEFAULT_REVIEW_SPACE);
38
+ }
39
+
40
+ /** Prefix for the per-target child Space and the published project name.
41
+ * Setting: `reviewNamePrefix`. The `-<slug>-v<version>-<date>` suffix stays fixed. */
42
+ export function reviewNamePrefix(): string {
43
+ return readSetting('reviewNamePrefix', DEFAULT_REVIEW_NAME_PREFIX);
44
+ }
45
+
46
+ /**
47
+ * Whether to re-open each published dashboard and assert it survives a reload
48
+ * (the heavy round-trip check). Default ON — keep it for client deliverables;
49
+ * turn it off via the `verifyPublishedDashboard` package setting for faster demo
50
+ * shares. The reviewer-access verify-and-rollback gate is separate and always runs.
51
+ */
52
+ export function verifyPublishedDashboard(): boolean {
53
+ try {
54
+ const v = (_package?.settings as Record<string, unknown> | undefined)?.['verifyPublishedDashboard'];
55
+ if (v === false || v === 'false') return false;
56
+ if (v === true || v === 'true') return true;
57
+ } catch { /* settings not ready — use default */ }
58
+ return true;
59
+ }