@dev.fast/whiteboard 0.0.0-stage → 0.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,151 @@
1
+ import { type JsonValue, isJsonObject } from "@dev.fast/review-protocol";
2
+ import { z } from "zod";
3
+
4
+ import { migrateDiffSelections } from "./diff-selection-migration.js";
5
+ import { formatAnchor } from "./lens-selection.js";
6
+ import { label } from "./review-api/blocks/definition.js";
7
+ import { type Lens, lensTargetsSchema } from "./review-api/diff-lenses.js";
8
+
9
+ /** Upgrade a saved or shared Review document to the current block schema at the
10
+ * read/import boundary. New edits use the strict schema and never accept these
11
+ * retired forms. */
12
+ // This is the decoder boundary for stored documents in retired wire formats.
13
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters
14
+ export function migrateStoredDocument(input: unknown): JsonValue {
15
+ return anchorStrings(dropSectionStatus(migrateDiffSelections(input)));
16
+ }
17
+
18
+ /** Lenses saved before ranges were anchor strings. */
19
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Decoder boundary for stored lenses in retired formats.
20
+ export function migrateStoredLenses(input: unknown): JsonValue {
21
+ return anchorStrings(migrateDiffSelections(input));
22
+ }
23
+
24
+ const ANCHOR_KEYS = ["source", "sources", "callSite", "contextSources"];
25
+
26
+ const selectionObjectSchema = z.object({
27
+ file: z.string(),
28
+ start: z.object({ side: z.enum(["base", "head"]), line: z.number() }),
29
+ end: z.object({ side: z.enum(["base", "head"]), line: z.number() }),
30
+ pins: z.json().optional(),
31
+ });
32
+
33
+ /**
34
+ * Anchors were once selection objects, each with its own pins. They are now
35
+ * strings, and pins sit on whatever holds them (a code peek, step, frame,
36
+ * attachment or operation) when they differ from the enclosing block's. No
37
+ * stored element mixed pins across its anchors. Lens ranges read at the
38
+ * review's pins and never carried others.
39
+ */
40
+ export function anchorStrings(
41
+ value: JsonValue,
42
+ blockPins?: JsonValue,
43
+ ): JsonValue {
44
+ if (Array.isArray(value))
45
+ return value.map((child) => anchorStrings(child, blockPins));
46
+
47
+ if (!isJsonObject(value)) return value;
48
+
49
+ const inherited = value.pins ?? blockPins;
50
+ let pins: JsonValue | undefined;
51
+
52
+ const convert = (anchor: JsonValue): JsonValue => {
53
+ const selection = selectionObjectSchema.safeParse(anchor);
54
+
55
+ if (!selection.success) return anchor;
56
+
57
+ const { pins: own, ...anchorAt } = selection.data;
58
+
59
+ if (own) pins = own;
60
+
61
+ return formatAnchor(anchorAt);
62
+ };
63
+
64
+ const migrated = Object.fromEntries(
65
+ Object.entries(value).map(([key, child]) => [
66
+ key,
67
+ ANCHOR_KEYS.includes(key)
68
+ ? Array.isArray(child)
69
+ ? child.map(convert)
70
+ : convert(child)
71
+ : anchorStrings(child, inherited),
72
+ ]),
73
+ );
74
+
75
+ if (
76
+ pins !== undefined &&
77
+ value.kind !== "ranges" &&
78
+ JSON.stringify(pins) !== JSON.stringify(blockPins)
79
+ )
80
+ migrated.pins = pins;
81
+
82
+ return migrated;
83
+ }
84
+
85
+ /** Sections once carried an optional `status` (pending, in_progress or
86
+ * complete). The authoring lease is now the only signal of work in progress,
87
+ * so versions saved before it was retired drop the field when read. */
88
+ function dropSectionStatus(value: JsonValue): JsonValue {
89
+ if (Array.isArray(value)) return value.map(dropSectionStatus);
90
+
91
+ if (!isJsonObject(value)) return value;
92
+
93
+ return Object.fromEntries(
94
+ Object.entries(value)
95
+ .filter(([key]) => !(value.type === "section" && key === "status"))
96
+ .map(([key, child]) => [key, dropSectionStatus(child)]),
97
+ );
98
+ }
99
+
100
+ /** A saved `file_lens` block, from before lenses left the document. */
101
+ const legacyFileLensSchema = z.object({
102
+ type: z.literal("file_lens"),
103
+ id: label,
104
+ title: label,
105
+ targets: lensTargetsSchema.optional(),
106
+ patterns: z.array(label).min(1).optional(),
107
+ });
108
+
109
+ export interface LiftedLenses {
110
+ document: JsonValue;
111
+ lenses: Lens[];
112
+ }
113
+
114
+ /** Versions saved before lenses moved out of the document hold them as
115
+ * `file_lens` blocks, possibly inside sections. Lift them out of a migrated
116
+ * document, in document order, keeping each id, title and scope (legacy
117
+ * `patterns` become a files target). Viewed state is per file, so it needs
118
+ * no rewrite. */
119
+ export function liftFileLenses(document: JsonValue): LiftedLenses {
120
+ const lenses: Lens[] = [];
121
+
122
+ const visit = (value: JsonValue): JsonValue => {
123
+ if (Array.isArray(value))
124
+ return value.flatMap((item) => {
125
+ if (!isJsonObject(item) || item.type !== "file_lens")
126
+ return [visit(item)];
127
+
128
+ const legacy = legacyFileLensSchema.safeParse(item);
129
+
130
+ // A malformed legacy lens is dropped rather than failing the read.
131
+ if (legacy.success && (legacy.data.targets ?? legacy.data.patterns))
132
+ lenses.push({
133
+ id: legacy.data.id,
134
+ title: legacy.data.title,
135
+ targets: legacy.data.targets ?? [
136
+ { kind: "files", patterns: legacy.data.patterns! },
137
+ ],
138
+ });
139
+
140
+ return [];
141
+ });
142
+
143
+ if (!isJsonObject(value)) return value;
144
+
145
+ return Object.fromEntries(
146
+ Object.entries(value).map(([key, child]) => [key, visit(child)]),
147
+ );
148
+ };
149
+
150
+ return { document: visit(document), lenses };
151
+ }
@@ -0,0 +1,257 @@
1
+ // Cleans a free-text telemetry value: replaces paths, web addresses, e-mail
2
+ // addresses, and known secret formats with labelled markers.
3
+ //
4
+ // PROVENANCE. This is a port of Microsoft's implementation in VS Code, which
5
+ // this product is a fork of. Source:
6
+ // apps/review-desktop/code-oss/src/vs/platform/telemetry/common/telemetryUtils.ts
7
+ // (`anonymizeFilePaths`, `userDataRegexes`, `redactIfPossibleUserInfo`,
8
+ // `removePropertiesWithPossibleUserInfo`)
9
+ // Licensed under the MIT License, Copyright (c) Microsoft Corporation.
10
+ //
11
+ // It lives here rather than being imported because the local Review server and
12
+ // the canvas both need it and neither can reach into the vendored VS Code tree.
13
+ // Keep it a FAITHFUL copy: upstream comments and expressions are preserved
14
+ // verbatim so a later reader can diff this file against upstream and see at a
15
+ // glance whether it has drifted. Behaviour changes belong in the caller.
16
+ //
17
+ // The functions are pure and free of Node imports, so the browser-safe event
18
+ // allowlist can share the expression list for its own independent re-check.
19
+ //
20
+ // DEVIATIONS FROM UPSTREAM, and only these:
21
+ // - Upstream keeps the tail of a `.vscode*/extensions/…` path so a stack stays
22
+ // attributable to an extension. Review reports no extension-host errors and
23
+ // cleans a message rather than a stack, so that branch is dropped and such a
24
+ // path is replaced whole. The `node_modules` branch is kept: a message can
25
+ // name a module usefully.
26
+ // - `cleanData`, upstream's object walker, is not ported. Review cleans two
27
+ // known strings, so walking an arbitrary object is not needed, and not
28
+ // having it means there is no path by which an unreviewed property could be
29
+ // cleaned-and-forwarded.
30
+
31
+ export const USER_DATA_REGEXES = [
32
+ { label: "URL", regex: /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*/ },
33
+ { label: "Google API Key", regex: /AIza[A-Za-z0-9_\\\-]{35}/ },
34
+ {
35
+ label: "JWT",
36
+ regex:
37
+ /eyJ[0eXAiOiJKV1Qi|hbGci|a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+/,
38
+ },
39
+ { label: "Slack Token", regex: /xox[pbar]\-[A-Za-z0-9]/ },
40
+ {
41
+ label: "GitHub Token",
42
+ regex:
43
+ /(gh[psuro]_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})/,
44
+ },
45
+ {
46
+ label: "Generic Secret",
47
+ regex:
48
+ /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/i,
49
+ },
50
+ {
51
+ label: "CLI Credentials",
52
+ regex:
53
+ /((login|psexec|(certutil|psexec)\.exe).{1,50}(\s-u(ser(name)?)?\s+.{3,100})?\s-(admin|user|vm|root)?p(ass(word)?)?\s+["']?[^$\-\/\s]|(^|[\s\r\n\\])net(\.exe)?.{1,5}(user\s+|share\s+\/user:| user -? secrets ? set) \s + [^ $\s \/])/,
54
+ },
55
+ {
56
+ label: "Microsoft Entra ID",
57
+ regex: /eyJ(?:0eXAiOiJKV1Qi|hbGci|[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.)/,
58
+ },
59
+ { label: "Email", regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/ },
60
+ ] as const;
61
+
62
+ const REDACTED_PATH_MARKER = "<REDACTED: user-file-path>";
63
+
64
+ /**
65
+ * The path shape the cleaner replaces: one or more `segment/` runs, with an
66
+ * optional drive letter, UNC, or `file://` root. Upstream keeps this inline in
67
+ * `anonymizeFilePaths`; it is named here so the allowlist can assert that no
68
+ * path shape survived cleaning.
69
+ */
70
+ const FILE_PATH_PATTERN =
71
+ /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w\-\._@]+(\\\\|\\|\/))+[\w\-\._@]*/;
72
+
73
+ /**
74
+ * True when the value still holds something path-shaped. Deliberately the same
75
+ * definition the cleaner uses: the point of re-testing is not a second opinion
76
+ * on what a path looks like, it is to catch the cleaner failing to apply its own
77
+ * rule — wrong patterns, wrong order, or an exception swallowed on the way.
78
+ */
79
+ export function containsFilePath(value: string): boolean {
80
+ return FILE_PATH_PATTERN.test(value);
81
+ }
82
+
83
+ /**
84
+ * True when the value still holds something the cleaner should have replaced.
85
+ * The event allowlist calls this as its own second check, so it must not depend
86
+ * on the cleaner having run.
87
+ */
88
+ export function hasPossibleUserInfo(value: string): boolean {
89
+ return USER_DATA_REGEXES.some((entry) => entry.regex.test(value));
90
+ }
91
+
92
+ /**
93
+ * Cleans a given stack of possible paths
94
+ * @param stack The stack to sanitize
95
+ * @param cleanupPatterns Cleanup patterns to remove from the stack
96
+ * @returns The cleaned stack
97
+ */
98
+ export function anonymizeFilePaths(
99
+ stack: string,
100
+ cleanupPatterns: RegExp[],
101
+ ): string {
102
+ // Fast check to see if it is a file path to avoid doing unnecessary heavy regex work
103
+ if (!stack || (!stack.includes("/") && !stack.includes("\\"))) {
104
+ return stack;
105
+ }
106
+
107
+ let updatedStack = stack;
108
+
109
+ const cleanUpIndexes: [number, number][] = [];
110
+
111
+ for (const regexp of cleanupPatterns) {
112
+ while (true) {
113
+ const result = regexp.exec(stack);
114
+
115
+ if (!result) {
116
+ break;
117
+ }
118
+
119
+ cleanUpIndexes.push([result.index, regexp.lastIndex]);
120
+ }
121
+ }
122
+
123
+ // Match node_modules or node_modules.asar at any position in the path, capturing the node_modules/... suffix
124
+ const nodeModulesRegex =
125
+ /(?:^|[\\\/])((node_modules|node_modules\.asar)[\\\/].*)$/;
126
+
127
+ const fileRegex =
128
+ /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w\-\._@]+(\\\\|\\|\/))+[\w\-\._@]*/g;
129
+
130
+ let lastIndex = 0;
131
+ updatedStack = "";
132
+
133
+ while (true) {
134
+ const result = fileRegex.exec(stack);
135
+
136
+ if (!result) {
137
+ break;
138
+ }
139
+
140
+ // Check to see if the any cleanupIndexes partially overlap with this match
141
+ const overlappingRange = cleanUpIndexes.some(
142
+ ([start, end]) => result.index < end && start < fileRegex.lastIndex,
143
+ );
144
+
145
+ // anoynimize user file paths that do not need to be retained or cleaned up.
146
+ if (!overlappingRange) {
147
+ // Check if node_modules appears in the path — preserve node_modules/... suffix
148
+ const nodeModulesMatch = nodeModulesRegex.exec(result[0]);
149
+
150
+ if (nodeModulesMatch) {
151
+ updatedStack +=
152
+ stack.substring(lastIndex, result.index) +
153
+ `${REDACTED_PATH_MARKER}/` +
154
+ nodeModulesMatch[1];
155
+ } else {
156
+ updatedStack +=
157
+ stack.substring(lastIndex, result.index) + REDACTED_PATH_MARKER;
158
+ }
159
+
160
+ lastIndex = fileRegex.lastIndex;
161
+ }
162
+ }
163
+
164
+ if (lastIndex < stack.length) {
165
+ updatedStack += stack.substring(lastIndex);
166
+ }
167
+
168
+ return updatedStack;
169
+ }
170
+
171
+ /**
172
+ * Redacts a value if it contains commonly leaked PII.
173
+ * @param value The value returned (as-is) when no PII is detected
174
+ * @param probe The string actually matched against the PII heuristics. Defaults
175
+ * to `value`; callers may pass a value that includes a trailing delimiter (e.g. a
176
+ * newline) so that heuristics relying on a non-alphanumeric boundary match the
177
+ * same way they would against the original whole string.
178
+ * @returns A `<REDACTED: ...>` marker if the probe matched, otherwise `value`
179
+ */
180
+ function redactIfPossibleUserInfo(
181
+ value: string,
182
+ probe: string = value,
183
+ ): string {
184
+ for (const secretRegex of USER_DATA_REGEXES) {
185
+ if (secretRegex.regex.test(probe)) {
186
+ return `<REDACTED: ${secretRegex.label}>`;
187
+ }
188
+ }
189
+
190
+ return value;
191
+ }
192
+
193
+ /**
194
+ * Attempts to remove commonly leaked PII.
195
+ *
196
+ * When a match is found the check is applied per line so that a single suspicious
197
+ * frame (e.g. a stack frame containing a function name such as `getStorageKey`
198
+ * which matches the broad `Generic Secret` heuristic) only redacts that line —
199
+ * replacing it with a `<REDACTED: ...>` marker — instead of wiping the entire
200
+ * multi-line value such as a whole callstack.
201
+ * @param property The property whose offending lines will be replaced with a redaction marker if they contain user data
202
+ * @returns The new value for the property
203
+ */
204
+ export function removePropertiesWithPossibleUserInfo(property: string): string {
205
+ // If for some reason it is undefined we skip it (this shouldn't be possible);
206
+ if (!property) {
207
+ return property;
208
+ }
209
+
210
+ // Fast path: if nothing matches we return the value untouched without
211
+ // allocating.
212
+ if (!hasPossibleUserInfo(property)) {
213
+ return property;
214
+ }
215
+
216
+ // Single line values keep the original behavior of redacting the whole value.
217
+ if (!property.includes("\n")) {
218
+ return redactIfPossibleUserInfo(property);
219
+ }
220
+
221
+ // Multi-line values (e.g. callstacks) are redacted line-by-line so we only
222
+ // drop the offending lines and preserve the rest of the information.
223
+ const lines = property.split("\n");
224
+
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const probe = i < lines.length - 1 ? lines[i] + "\n" : lines[i];
227
+ lines[i] = redactIfPossibleUserInfo(lines[i], probe);
228
+ }
229
+
230
+ return lines.join("\n");
231
+ }
232
+
233
+ /**
234
+ * The whole pipeline for one free-text value, in VS Code's order: replace
235
+ * path-shaped runs, delete the known local directories outright, then replace
236
+ * any line holding a secret or address shape.
237
+ */
238
+ export function cleanTelemetryText(
239
+ value: string,
240
+ cleanupPatterns: RegExp[],
241
+ ): string {
242
+ let updated = value.replaceAll("%20", " ");
243
+ updated = anonymizeFilePaths(updated, cleanupPatterns);
244
+
245
+ for (const regexp of cleanupPatterns) {
246
+ updated = updated.replace(regexp, "");
247
+ }
248
+
249
+ return removePropertiesWithPossibleUserInfo(updated);
250
+ }
251
+
252
+ // `cleanupPatterns` above is the list of directories to delete outright rather
253
+ // than replace with a marker. Upstream builds it from five known directories;
254
+ // Review always passes an empty list, so what remains is the overlap check
255
+ // inherited from upstream. See NO_DELETED_DIRECTORIES in error-telemetry.ts for
256
+ // why. Should Review ever need the list, upstream builds it inline in
257
+ // telemetryService.ts with escaped, case-insensitive, global patterns.
@@ -0,0 +1,298 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { JsonValue } from "@dev.fast/review-protocol";
5
+ import { z } from "zod";
6
+
7
+ import { findReviewPackageRoot } from "./package-paths";
8
+ import { DEV_REVIEW_HOME_ENV, devReviewHome } from "./review-home-paths";
9
+
10
+ export interface ReviewTelemetryInstallConfig {
11
+ installationId: string;
12
+ /**
13
+ * When this install was created, ISO 8601. A config written before the
14
+ * field existed gets the first time a later version read it.
15
+ */
16
+ createdAt: string;
17
+ installationCreatedSent: boolean;
18
+ firstReviewPresentedSent: boolean;
19
+ enabled: boolean;
20
+ internal: boolean;
21
+ /** `gh_` + keyed hash of the signed-in account id; absent until a login. */
22
+ accountAlias?: string;
23
+ }
24
+
25
+ export type ReviewTelemetryChannel = "stable" | "preview" | "dev";
26
+
27
+ export type ReviewTelemetryEnvironment =
28
+ | "production"
29
+ | "ci"
30
+ | "internal"
31
+ | "e2e"
32
+ | "smoke";
33
+
34
+ export type ReviewTelemetrySurface =
35
+ | "desktop"
36
+ | "cli"
37
+ | "headless"
38
+ | "mcp"
39
+ | "api";
40
+
41
+ /** Set by Electron main from product.json `quality`; `dev` for an unpackaged run. */
42
+ export const REVIEW_CHANNEL_ENV = "DEV_FAST_REVIEW_CHANNEL";
43
+
44
+ /** Set by the e2e harness (`e2e`) and the packaged smoke scripts (`smoke`). */
45
+ export const REVIEW_TELEMETRY_ENV_ENV = "DEV_FAST_REVIEW_TELEMETRY_ENV";
46
+
47
+ const CHANNELS: readonly ReviewTelemetryChannel[] = [
48
+ "stable",
49
+ "preview",
50
+ "dev",
51
+ ];
52
+
53
+ /**
54
+ * PostHog groups events into sessions by `$session_id` and accepts only a
55
+ * UUIDv7 there, so only a v7 app session id doubles as one.
56
+ */
57
+ export const uuidV7Schema = z
58
+ .string()
59
+ .regex(
60
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
61
+ );
62
+
63
+ export function isUuidV7(value: unknown): value is string {
64
+ return uuidV7Schema.safeParse(value).success;
65
+ }
66
+
67
+ export function reviewTelemetryChannel(
68
+ env: NodeJS.ProcessEnv,
69
+ ): ReviewTelemetryChannel {
70
+ // SAFETY: the cast is provisional; CHANNELS.includes(value) below checks
71
+ // membership before the value is ever returned, falling back to "stable".
72
+ const value = env[REVIEW_CHANNEL_ENV]?.trim() as ReviewTelemetryChannel;
73
+
74
+ return CHANNELS.includes(value) ? value : "stable";
75
+ }
76
+
77
+ /**
78
+ * First match wins: a harness declares itself, then CI, then a dev.fast
79
+ * checkout or a persisted internal marker, else a real user.
80
+ */
81
+ export function reviewTelemetryEnvironment(
82
+ env: NodeJS.ProcessEnv,
83
+ config?: Pick<ReviewTelemetryInstallConfig, "internal">,
84
+ ): ReviewTelemetryEnvironment {
85
+ const harness = env[REVIEW_TELEMETRY_ENV_ENV]?.trim();
86
+
87
+ if (harness === "e2e" || harness === "smoke") return harness;
88
+
89
+ if (env.CI) return "ci";
90
+
91
+ if (isInternalTelemetry(env, config)) return "internal";
92
+
93
+ return "production";
94
+ }
95
+
96
+ const TELEMETRY_CONFIG_RELATIVE_PATH = path.join(
97
+ "telemetry",
98
+ "progressive-review.json",
99
+ );
100
+
101
+ const PREVIEW_TELEMETRY_CONFIG_RELATIVE_PATH = path.join(
102
+ "telemetry",
103
+ "progressive-review.preview.json",
104
+ );
105
+
106
+ const LEGACY_APP_TELEMETRY_CONFIG_RELATIVE_PATH = path.join(
107
+ "telemetry",
108
+ "install.json",
109
+ );
110
+
111
+ export function reviewTelemetryConfigPath(
112
+ env: NodeJS.ProcessEnv = process.env,
113
+ ): string {
114
+ return path.join(
115
+ devReviewHome(env),
116
+ reviewTelemetryChannel(env) === "preview"
117
+ ? PREVIEW_TELEMETRY_CONFIG_RELATIVE_PATH
118
+ : TELEMETRY_CONFIG_RELATIVE_PATH,
119
+ );
120
+ }
121
+
122
+ export function legacyAppTelemetryConfigPath(
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): string {
125
+ return path.join(
126
+ devReviewHome(env),
127
+ LEGACY_APP_TELEMETRY_CONFIG_RELATIVE_PATH,
128
+ );
129
+ }
130
+
131
+ export function isTelemetryOptedOut(
132
+ env: NodeJS.ProcessEnv,
133
+ config?: Pick<ReviewTelemetryInstallConfig, "enabled">,
134
+ ): boolean {
135
+ // Test runners must never emit real telemetry: every vitest/node-test run
136
+ // with a temp DEV_REVIEW_HOME mints a fresh installation id and floods the
137
+ // installation and command metrics. Telemetry's own unit tests inject fake
138
+ // capture clients, so they are unaffected by this guard.
139
+ if (isEnabledEnvValue(env.VITEST) || env.NODE_ENV === "test") return true;
140
+
141
+ if (config?.enabled === false) return true;
142
+
143
+ // Keep every historical spelling so existing shell and CI configurations
144
+ // continue to disable telemetry after package and product renames.
145
+ return [
146
+ env.DO_NOT_TRACK,
147
+ env.DNT,
148
+ env.PROGRESSIVE_REVIEW_TELEMETRY_DISABLED,
149
+ env.DEV_FAST_TELEMETRY_DISABLED,
150
+ env.DEV_FAST_PROGRESSIVE_REVIEW_TELEMETRY_DISABLED,
151
+ env.DEV_FAST_REVIEW_TELEMETRY_DISABLED,
152
+ ].some(isEnabledEnvValue);
153
+ }
154
+
155
+ /**
156
+ * The on-disk install config as a hand-edited or older file may hold it: only
157
+ * the installation id is required, and a malformed optional field reads as
158
+ * absent.
159
+ */
160
+ const storedTelemetryInstallConfigSchema = z.looseObject({
161
+ installationId: z.string().min(1),
162
+ createdAt: z.iso.datetime({ offset: true }).optional().catch(undefined),
163
+ installationCreatedSent: z.boolean().optional().catch(undefined),
164
+ firstReviewPresentedSent: z.boolean().optional().catch(undefined),
165
+ enabled: z.boolean().optional().catch(undefined),
166
+ internal: z.boolean().optional().catch(undefined),
167
+ accountAlias: z.string().min(1).optional().catch(undefined),
168
+ });
169
+
170
+ export function normalizeTelemetryInstallConfig(
171
+ parsed: JsonValue,
172
+ now: () => Date,
173
+ ): ReviewTelemetryInstallConfig | undefined {
174
+ const stored = storedTelemetryInstallConfigSchema.safeParse(parsed);
175
+
176
+ if (!stored.success) return undefined;
177
+
178
+ const config: ReviewTelemetryInstallConfig = {
179
+ installationId: stored.data.installationId,
180
+ createdAt: stored.data.createdAt ?? now().toISOString(),
181
+ installationCreatedSent: stored.data.installationCreatedSent === true,
182
+ firstReviewPresentedSent: stored.data.firstReviewPresentedSent === true,
183
+ enabled: stored.data.enabled !== false,
184
+ internal: stored.data.internal === true,
185
+ };
186
+
187
+ if (stored.data.accountAlias) config.accountAlias = stored.data.accountAlias;
188
+
189
+ return config;
190
+ }
191
+
192
+ /**
193
+ * Whether normalizing changed a field an older or hand-edited file lacked, so
194
+ * the config must be written back once: a backfilled `createdAt` has to stay
195
+ * the first-seen time rather than move with every read.
196
+ */
197
+ export function telemetryInstallConfigNeedsWrite(
198
+ parsed: JsonValue,
199
+ config: ReviewTelemetryInstallConfig,
200
+ ): boolean {
201
+ const stored = storedTelemetryInstallConfigSchema.safeParse(parsed).data;
202
+
203
+ return (
204
+ stored?.internal !== config.internal ||
205
+ stored?.createdAt !== config.createdAt
206
+ );
207
+ }
208
+
209
+ const DAY_MS = 24 * 60 * 60 * 1_000;
210
+
211
+ /** Whole days since the install was created; 0 for a clock set back. */
212
+ export function installAgeDays(
213
+ config: Pick<ReviewTelemetryInstallConfig, "createdAt">,
214
+ now: Date,
215
+ ): number {
216
+ const createdAt = Date.parse(config.createdAt);
217
+
218
+ if (Number.isNaN(createdAt)) return 0;
219
+
220
+ return Math.max(0, Math.floor((now.getTime() - createdAt) / DAY_MS));
221
+ }
222
+
223
+ export function createTelemetryInstallConfig(
224
+ installationId: string,
225
+ now: () => Date,
226
+ ): ReviewTelemetryInstallConfig {
227
+ return {
228
+ installationId,
229
+ createdAt: now().toISOString(),
230
+ installationCreatedSent: false,
231
+ firstReviewPresentedSent: false,
232
+ enabled: true,
233
+ internal: false,
234
+ };
235
+ }
236
+
237
+ function isEnabledEnvValue(value: string | undefined): boolean {
238
+ return value === "1" || value?.toLowerCase() === "true";
239
+ }
240
+
241
+ let cachedWorkspaceCheckout: boolean | undefined;
242
+
243
+ // Running from a workspace checkout (the dev.fast monorepo or any pnpm
244
+ // workspace clone) means the traffic is ours, not a customer's. A published
245
+ // npm install always lives under node_modules and has no workspace manifest
246
+ // above it.
247
+ function isWorkspaceCheckout(): boolean {
248
+ if (cachedWorkspaceCheckout !== undefined) return cachedWorkspaceCheckout;
249
+
250
+ try {
251
+ const packageRoot = findReviewPackageRoot();
252
+
253
+ if (packageRoot.split(path.sep).includes("node_modules")) {
254
+ cachedWorkspaceCheckout = false;
255
+
256
+ return cachedWorkspaceCheckout;
257
+ }
258
+
259
+ let dir = packageRoot;
260
+
261
+ while (true) {
262
+ if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
263
+ cachedWorkspaceCheckout = true;
264
+
265
+ return cachedWorkspaceCheckout;
266
+ }
267
+
268
+ const parent = path.dirname(dir);
269
+
270
+ if (parent === dir) break;
271
+ dir = parent;
272
+ }
273
+
274
+ cachedWorkspaceCheckout = false;
275
+ } catch {
276
+ cachedWorkspaceCheckout = false;
277
+ }
278
+
279
+ return cachedWorkspaceCheckout;
280
+ }
281
+
282
+ /**
283
+ * Whether telemetry from this process should carry `internal: true` so
284
+ * partner-facing dashboards can exclude it. The environment overrides a
285
+ * stored true marker, which overrides workspace detection.
286
+ */
287
+ export function isInternalTelemetry(
288
+ env: NodeJS.ProcessEnv,
289
+ config?: Pick<ReviewTelemetryInstallConfig, "internal">,
290
+ ): boolean {
291
+ if (env.PROGRESSIVE_REVIEW_TELEMETRY_INTERNAL === "1") return true;
292
+
293
+ if (env.PROGRESSIVE_REVIEW_TELEMETRY_INTERNAL === "0") return false;
294
+
295
+ if (config?.internal === true) return true;
296
+
297
+ return isWorkspaceCheckout();
298
+ }