@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { lstat, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { valid as validVersion } from "semver";
|
|
5
|
+
|
|
6
|
+
export const LEGACY_SKILL_NAMES = [
|
|
7
|
+
"whiteboard",
|
|
8
|
+
"dev-review",
|
|
9
|
+
"dev-review-batch",
|
|
10
|
+
"dev-file-lenses",
|
|
11
|
+
"scratchpad",
|
|
12
|
+
"trace-archaeology",
|
|
13
|
+
"dev-review-map",
|
|
14
|
+
"review",
|
|
15
|
+
"review-map",
|
|
16
|
+
"review-stop",
|
|
17
|
+
"progressive-review",
|
|
18
|
+
"pr-review",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function legacySkillRoots(homeDir: string): string[] {
|
|
22
|
+
return [
|
|
23
|
+
path.join(homeDir, ".claude", "skills"),
|
|
24
|
+
path.join(homeDir, ".cursor", "skills"),
|
|
25
|
+
path.join(homeDir, ".config", "opencode", "skills"),
|
|
26
|
+
path.join(homeDir, ".agents", "skills"),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True only for a real skill directory (never a symlink to someone else's)
|
|
32
|
+
* holding a regular SKILL.md whose frontmatter Review Desktop wrote.
|
|
33
|
+
*/
|
|
34
|
+
async function isReviewStamped(skillDir: string): Promise<boolean> {
|
|
35
|
+
const file = path.join(skillDir, "SKILL.md");
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (!(await lstat(skillDir)).isDirectory()) return false;
|
|
39
|
+
|
|
40
|
+
if (!(await lstat(file)).isFile()) return false;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const source = await readTextOrUndefined(file);
|
|
46
|
+
|
|
47
|
+
// An unreadable file cannot be verified as Review's, so it is never removed.
|
|
48
|
+
if (source === undefined) return false;
|
|
49
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source)?.[1];
|
|
50
|
+
|
|
51
|
+
const metadata = frontmatter?.match(
|
|
52
|
+
/^metadata:\r?\n((?:[ \t]+[^\n]*(?:\n|$))*)/m,
|
|
53
|
+
)?.[1];
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
!metadata ||
|
|
57
|
+
!(
|
|
58
|
+
(/^ review-managed-by: "Review Desktop"\r?$/m.test(metadata) &&
|
|
59
|
+
/^ review-generated: "[^"\r\n]+"\r?$/m.test(metadata)) ||
|
|
60
|
+
(/^ whiteboard-managed-by: "Whiteboard(?: Desktop)?"\r?$/m.test(
|
|
61
|
+
metadata,
|
|
62
|
+
) &&
|
|
63
|
+
/^ whiteboard-generated: "[^"\r\n]+"\r?$/m.test(metadata))
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return false;
|
|
67
|
+
|
|
68
|
+
const version = metadata.match(
|
|
69
|
+
/^ (?:review|whiteboard)-version: "([^"\r\n]+)"\r?$/m,
|
|
70
|
+
)?.[1];
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
version !== undefined &&
|
|
74
|
+
(version === "development" || validVersion(version) !== null)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const OPENCODE_PLUGIN_MARKER =
|
|
79
|
+
"// Managed by Review Desktop (@dev.fast/review).";
|
|
80
|
+
|
|
81
|
+
/** The OpenCode plugin file Review Desktop wrote before it connected over MCP. */
|
|
82
|
+
async function isLegacyOpenCodePlugin(file: string): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
if (!(await lstat(file)).isFile()) return false;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const source = await readTextOrUndefined(file);
|
|
90
|
+
|
|
91
|
+
return source?.split(/\r?\n/, 1)[0] === OPENCODE_PLUGIN_MARKER;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readTextOrUndefined(file: string): Promise<string | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
return await readFile(file, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function scanLegacySkills(homeDir: string): Promise<string[]> {
|
|
103
|
+
const found: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const name of ["review.ts", "whiteboard.ts"]) {
|
|
106
|
+
const plugin = path.join(homeDir, ".config", "opencode", "plugins", name);
|
|
107
|
+
|
|
108
|
+
if (await isLegacyOpenCodePlugin(plugin)) found.push(plugin);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const root of legacySkillRoots(homeDir)) {
|
|
112
|
+
for (const name of LEGACY_SKILL_NAMES) {
|
|
113
|
+
const skillDir = path.join(root, name);
|
|
114
|
+
|
|
115
|
+
if (await isReviewStamped(skillDir)) found.push(skillDir);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return found.sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function removeLegacySkills(
|
|
123
|
+
homeDir: string,
|
|
124
|
+
): Promise<{ removed: string[] }> {
|
|
125
|
+
const removed: string[] = [];
|
|
126
|
+
|
|
127
|
+
for (const skillDir of await scanLegacySkills(homeDir)) {
|
|
128
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
129
|
+
removed.push(skillDir);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { removed };
|
|
133
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { type FileLineRange, type SourcePins } from "./source.js";
|
|
4
|
+
import { unionIntervals } from "./viewed-coverage.js";
|
|
5
|
+
|
|
6
|
+
const ANCHOR_FORMS =
|
|
7
|
+
'"head/path#L10-L24" or "base/path#L7" (one side, lines 10–24 or line 7); across sides, GitHub diff style "diff/path#L84-R90" (L = base line, R = head line)';
|
|
8
|
+
|
|
9
|
+
/** Inclusive endpoints in the uncollapsed alignment, independent of diff
|
|
10
|
+
* layout. `pins` names the repository and commits the selection was read
|
|
11
|
+
* from; a selection without them resolves against its document's pins. */
|
|
12
|
+
export type DiffSelection = {
|
|
13
|
+
file: string;
|
|
14
|
+
start: { side: "base" | "head"; line: number };
|
|
15
|
+
end: { side: "base" | "head"; line: number };
|
|
16
|
+
pins?: SourcePins;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A selection as written and stored: "head/path#L10-L24", "base/path#L7",
|
|
20
|
+
* or "diff/path#L84-R90" across sides. Pins live on whatever holds it. */
|
|
21
|
+
export type Anchor = string;
|
|
22
|
+
|
|
23
|
+
const sameSideAnchor = /^(head|base)\/(.+)#L(\d+)(?:-L(\d+))?$/;
|
|
24
|
+
|
|
25
|
+
const crossSideAnchor = /^diff\/(.+)#([LR])(\d+)-([LR])(\d+)$/;
|
|
26
|
+
|
|
27
|
+
/** An anchor's selection, as markdown source links write it too (without
|
|
28
|
+
* their `review-source:` scheme). Undefined when it isn't one. */
|
|
29
|
+
export function parseAnchor(text: string): DiffSelection | undefined {
|
|
30
|
+
const same = sameSideAnchor.exec(text);
|
|
31
|
+
|
|
32
|
+
if (same) {
|
|
33
|
+
const side = same[1] === "base" ? "base" : "head";
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
file: same[2]!,
|
|
37
|
+
start: { side, line: Number(same[3]) },
|
|
38
|
+
end: { side, line: Number(same[4] ?? same[3]) },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cross = crossSideAnchor.exec(text);
|
|
43
|
+
|
|
44
|
+
// One side has its own spelling, so a diff anchor must cross.
|
|
45
|
+
if (!cross || cross[2] === cross[4]) return undefined;
|
|
46
|
+
const side = (letter: string) => (letter === "L" ? "base" : "head");
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
file: cross[1]!,
|
|
50
|
+
start: { side: side(cross[2]!), line: Number(cross[3]) },
|
|
51
|
+
end: { side: side(cross[4]!), line: Number(cross[5]) },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why a parsed selection can't be quoted, or undefined when it can. */
|
|
56
|
+
export function selectionProblem(selection: DiffSelection): string | undefined {
|
|
57
|
+
if (selection.start.line < 1 || selection.end.line < 1)
|
|
58
|
+
return "Source lines count from 1.";
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
selection.start.side === selection.end.side &&
|
|
62
|
+
selection.start.line > selection.end.line
|
|
63
|
+
)
|
|
64
|
+
return "Source range ends before it starts.";
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
selection.pins &&
|
|
68
|
+
selection.pins.base === undefined &&
|
|
69
|
+
(selection.start.side === "base" || selection.end.side === "base")
|
|
70
|
+
)
|
|
71
|
+
return "A base-side anchor needs base pins.";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const anchorSchema = z
|
|
75
|
+
.string({ error: `Expected a source anchor: ${ANCHOR_FORMS}.` })
|
|
76
|
+
.superRefine((text, context) => {
|
|
77
|
+
const selection = parseAnchor(text);
|
|
78
|
+
|
|
79
|
+
const problem = selection
|
|
80
|
+
? selectionProblem(selection)
|
|
81
|
+
: `Use ${ANCHOR_FORMS} for a source anchor.`;
|
|
82
|
+
|
|
83
|
+
if (problem) context.addIssue({ code: "custom", message: problem });
|
|
84
|
+
})
|
|
85
|
+
.describe("head/path#L10-L24, base/path#L7, or diff/path#L84-R90");
|
|
86
|
+
|
|
87
|
+
/** A stored anchor's selection, read at the pins of whatever holds it. */
|
|
88
|
+
export function anchorSelection(
|
|
89
|
+
anchor: Anchor,
|
|
90
|
+
pins?: SourcePins,
|
|
91
|
+
): DiffSelection {
|
|
92
|
+
const selection = parseAnchor(anchor);
|
|
93
|
+
|
|
94
|
+
if (!selection) throw new Error(`Not a source anchor: ${anchor}`);
|
|
95
|
+
|
|
96
|
+
return pins ? { ...selection, pins } : selection;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The anchor that writes a selection; its pins belong to its holder. */
|
|
100
|
+
export function formatAnchor(selection: DiffSelection): Anchor {
|
|
101
|
+
const { file, start, end } = selection;
|
|
102
|
+
|
|
103
|
+
if (start.side === end.side)
|
|
104
|
+
return start.line === end.line
|
|
105
|
+
? `${start.side}/${file}#L${start.line}`
|
|
106
|
+
: `${start.side}/${file}#L${start.line}-L${end.line}`;
|
|
107
|
+
|
|
108
|
+
const letter = (side: "base" | "head") => (side === "base" ? "L" : "R");
|
|
109
|
+
|
|
110
|
+
return `diff/${file}#${letter(start.side)}${start.line}-${letter(end.side)}${end.line}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A selection as its holder stores it: the anchor, and its pins when it
|
|
114
|
+
* names its own. */
|
|
115
|
+
export function anchored(selection: DiffSelection): {
|
|
116
|
+
source: Anchor;
|
|
117
|
+
pins?: SourcePins;
|
|
118
|
+
} {
|
|
119
|
+
return selection.pins
|
|
120
|
+
? { source: formatAnchor(selection), pins: selection.pins }
|
|
121
|
+
: { source: formatAnchor(selection) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type LensSource = DiffSelection;
|
|
125
|
+
|
|
126
|
+
export type AlignmentRow = readonly [number | null, number | null];
|
|
127
|
+
|
|
128
|
+
/** Pins-less selections keep the key they always had. */
|
|
129
|
+
export function selectionKey(source: LensSource): string {
|
|
130
|
+
return JSON.stringify([
|
|
131
|
+
source.file,
|
|
132
|
+
source.start.side,
|
|
133
|
+
source.start.line,
|
|
134
|
+
source.end.side,
|
|
135
|
+
source.end.line,
|
|
136
|
+
...(source.pins ? [sourcePinsKey(source.pins)] : []),
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function sourcePinsKey(pins: SourcePins): string {
|
|
141
|
+
return `${pins.repositoryId}:${pins.base ?? ""}:${pins.head}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The comparison a source's pins name: head alone means head against
|
|
145
|
+
* itself. Reference coverage is grouped under this key on both sides. */
|
|
146
|
+
export function comparisonKey(pins: SourcePins): string {
|
|
147
|
+
return `${pins.repositoryId}:${pins.base ?? pins.head}:${pins.head}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The anchor for one side's line range; its pins belong to its holder. */
|
|
151
|
+
export function rangeAnchor(
|
|
152
|
+
source: Pick<FileLineRange, "file" | "side" | "fromLine" | "toLine">,
|
|
153
|
+
): Anchor {
|
|
154
|
+
return formatAnchor({
|
|
155
|
+
file: source.file,
|
|
156
|
+
start: { side: source.side, line: source.fromLine },
|
|
157
|
+
end: { side: source.side, line: source.toLine },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Adapt internal source links (for example software-map evidence) to a selection. */
|
|
162
|
+
export function selectSource(source: FileLineRange): DiffSelection {
|
|
163
|
+
const selection: DiffSelection = {
|
|
164
|
+
file: source.file,
|
|
165
|
+
start: { side: source.side, line: source.fromLine },
|
|
166
|
+
end: { side: source.side, line: source.toLine },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return source.pins ? { ...selection, pins: source.pins } : selection;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Navigation/quote anchors only; never use these as lens coverage. */
|
|
173
|
+
export function sourceAnchors(source: LensSource): FileLineRange[] {
|
|
174
|
+
return (["head", "base"] as const).flatMap((side) => {
|
|
175
|
+
const lines = [source.start, source.end].flatMap((endpoint) =>
|
|
176
|
+
endpoint.side === side ? [endpoint.line] : [],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!lines.length) return [];
|
|
180
|
+
|
|
181
|
+
const anchor: FileLineRange = {
|
|
182
|
+
file: source.file,
|
|
183
|
+
side,
|
|
184
|
+
fromLine: Math.min(...lines),
|
|
185
|
+
toLine: Math.max(...lines),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (source.pins) anchor.pins = source.pins;
|
|
189
|
+
|
|
190
|
+
return [anchor];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function sourceAnchor(source: LensSource): FileLineRange {
|
|
195
|
+
return sourceAnchors(source)[0]!;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Resolve once against the renderer's rows. No matching or context expansion. */
|
|
199
|
+
export function resolveDiffSelection(
|
|
200
|
+
source: LensSource,
|
|
201
|
+
rows: readonly AlignmentRow[],
|
|
202
|
+
file: { path: string; previousPath?: string },
|
|
203
|
+
): FileLineRange[] {
|
|
204
|
+
const locate = (endpoint: DiffSelection["start"]): number =>
|
|
205
|
+
rows.findIndex(
|
|
206
|
+
(row) => row[endpoint.side === "base" ? 0 : 1] === endpoint.line - 1,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const start = locate(source.start);
|
|
210
|
+
const end = locate(source.end);
|
|
211
|
+
|
|
212
|
+
if (start < 0 || end < start)
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Lens endpoints do not identify an ordered interval in this diff.",
|
|
215
|
+
);
|
|
216
|
+
const selected = rows.slice(start, end + 1);
|
|
217
|
+
|
|
218
|
+
return (["base", "head"] as const).flatMap((side, index) =>
|
|
219
|
+
unionIntervals(
|
|
220
|
+
selected.flatMap((row) =>
|
|
221
|
+
row[index] === null ? [] : [[row[index]!, row[index]! + 1]],
|
|
222
|
+
),
|
|
223
|
+
).map(([from, to]) => ({
|
|
224
|
+
file: side === "base" ? (file.previousPath ?? file.path) : file.path,
|
|
225
|
+
side,
|
|
226
|
+
fromLine: from + 1,
|
|
227
|
+
toLine: to,
|
|
228
|
+
})),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Extension as FromMarkdownExtension,
|
|
3
|
+
Handle,
|
|
4
|
+
} from "mdast-util-from-markdown";
|
|
5
|
+
import type {
|
|
6
|
+
Code,
|
|
7
|
+
Construct,
|
|
8
|
+
Extension,
|
|
9
|
+
State,
|
|
10
|
+
TokenizeContext,
|
|
11
|
+
} from "micromark-util-types";
|
|
12
|
+
|
|
13
|
+
declare module "micromark-util-types" {
|
|
14
|
+
interface TokenTypeMap {
|
|
15
|
+
latexInlineMath: "latexInlineMath";
|
|
16
|
+
latexDisplayMath: "latexDisplayMath";
|
|
17
|
+
latexMathSequence: "latexMathSequence";
|
|
18
|
+
latexMathData: "latexMathData";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const backslash = 92;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `\(…\)` inline and `\[…\]` display math, read ahead of character escapes.
|
|
26
|
+
* `\[` and `\]` count only on their own lines, so an escaped `\[1\]` stays prose.
|
|
27
|
+
*/
|
|
28
|
+
export function latexMath(): Extension {
|
|
29
|
+
return {
|
|
30
|
+
text: {
|
|
31
|
+
[backslash]: [
|
|
32
|
+
delimitedMath("latexInlineMath", 40, 41, false),
|
|
33
|
+
delimitedMath("latexDisplayMath", 91, 93, true),
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Builds the nodes `$` and `$$` produce. */
|
|
40
|
+
export function latexMathFromMarkdown(): FromMarkdownExtension {
|
|
41
|
+
return {
|
|
42
|
+
enter: {
|
|
43
|
+
latexInlineMath(token) {
|
|
44
|
+
this.enter({ type: "inlineMath", value: "" }, token);
|
|
45
|
+
this.buffer();
|
|
46
|
+
},
|
|
47
|
+
latexDisplayMath(token) {
|
|
48
|
+
this.enter({ type: "math", value: "" }, token);
|
|
49
|
+
this.buffer();
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
exit: {
|
|
53
|
+
latexInlineMath: exitMath,
|
|
54
|
+
latexDisplayMath: exitMath,
|
|
55
|
+
latexMathData(token) {
|
|
56
|
+
this.config.enter.data.call(this, token);
|
|
57
|
+
this.config.exit.data.call(this, token);
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const exitMath: Handle = function (token) {
|
|
64
|
+
const value = this.resume().trim();
|
|
65
|
+
const node = this.stack.at(-1);
|
|
66
|
+
|
|
67
|
+
if (node?.type !== "inlineMath" && node?.type !== "math")
|
|
68
|
+
throw new Error(`Expected a math node, found ${node?.type}.`);
|
|
69
|
+
node.value = value;
|
|
70
|
+
this.exit(token);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const lineEnding = (code: Code) => code !== null && code < -2;
|
|
74
|
+
|
|
75
|
+
// Space, tab and micromark's virtual space.
|
|
76
|
+
const lineSpace = (code: Code) => code === 32 || code === -2 || code === -1;
|
|
77
|
+
|
|
78
|
+
function delimitedMath(
|
|
79
|
+
type: "latexInlineMath" | "latexDisplayMath",
|
|
80
|
+
open: number,
|
|
81
|
+
close: number,
|
|
82
|
+
ownLine: boolean,
|
|
83
|
+
): Construct {
|
|
84
|
+
const closing: Construct = { tokenize: tokenizeClosing, partial: true };
|
|
85
|
+
|
|
86
|
+
return { name: type, tokenize: tokenizeMath };
|
|
87
|
+
|
|
88
|
+
function tokenizeMath(
|
|
89
|
+
this: TokenizeContext,
|
|
90
|
+
effects: Parameters<Construct["tokenize"]>[0],
|
|
91
|
+
ok: State,
|
|
92
|
+
nok: State,
|
|
93
|
+
): State {
|
|
94
|
+
let lineStart = false;
|
|
95
|
+
|
|
96
|
+
return start;
|
|
97
|
+
|
|
98
|
+
function start(code: Code): State | undefined {
|
|
99
|
+
effects.enter(type);
|
|
100
|
+
effects.enter("latexMathSequence");
|
|
101
|
+
effects.consume(code);
|
|
102
|
+
|
|
103
|
+
return opening;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function opening(code: Code): State | undefined {
|
|
107
|
+
if (code !== open) return nok(code);
|
|
108
|
+
effects.consume(code);
|
|
109
|
+
|
|
110
|
+
return ownLine ? openingLine : openingEnd;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openingLine(code: Code): State | undefined {
|
|
114
|
+
if (lineSpace(code)) {
|
|
115
|
+
effects.consume(code);
|
|
116
|
+
|
|
117
|
+
return openingLine;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lineEnding(code) ? openingEnd(code) : nok(code);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openingEnd(code: Code): State | undefined {
|
|
124
|
+
effects.exit("latexMathSequence");
|
|
125
|
+
|
|
126
|
+
return between(code);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function between(code: Code): State | undefined {
|
|
130
|
+
if (code === null) return nok(code);
|
|
131
|
+
|
|
132
|
+
if (code === backslash)
|
|
133
|
+
return ownLine && !lineStart
|
|
134
|
+
? escape(code)
|
|
135
|
+
: effects.attempt(closing, after, escape)(code);
|
|
136
|
+
|
|
137
|
+
if (lineEnding(code)) {
|
|
138
|
+
effects.enter("lineEnding");
|
|
139
|
+
effects.consume(code);
|
|
140
|
+
effects.exit("lineEnding");
|
|
141
|
+
lineStart = true;
|
|
142
|
+
|
|
143
|
+
return between;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
effects.enter("latexMathData");
|
|
147
|
+
|
|
148
|
+
return data(code);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `\\` is one unit, so the `)` in `\\)` never closes.
|
|
152
|
+
function escape(code: Code): State | undefined {
|
|
153
|
+
effects.enter("latexMathData");
|
|
154
|
+
effects.consume(code);
|
|
155
|
+
lineStart = false;
|
|
156
|
+
|
|
157
|
+
return escaped;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function escaped(code: Code): State | undefined {
|
|
161
|
+
if (code === null || lineEnding(code)) {
|
|
162
|
+
effects.exit("latexMathData");
|
|
163
|
+
|
|
164
|
+
return between(code);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
effects.consume(code);
|
|
168
|
+
|
|
169
|
+
return data;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function data(code: Code): State | undefined {
|
|
173
|
+
if (code === null || code === backslash || lineEnding(code)) {
|
|
174
|
+
effects.exit("latexMathData");
|
|
175
|
+
|
|
176
|
+
return between(code);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!lineSpace(code)) lineStart = false;
|
|
180
|
+
effects.consume(code);
|
|
181
|
+
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function after(code: Code): State | undefined {
|
|
186
|
+
effects.exit(type);
|
|
187
|
+
|
|
188
|
+
return ok(code);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function tokenizeClosing(
|
|
193
|
+
this: TokenizeContext,
|
|
194
|
+
effects: Parameters<Construct["tokenize"]>[0],
|
|
195
|
+
ok: State,
|
|
196
|
+
nok: State,
|
|
197
|
+
): State {
|
|
198
|
+
return start;
|
|
199
|
+
|
|
200
|
+
function start(code: Code): State | undefined {
|
|
201
|
+
effects.enter("latexMathSequence");
|
|
202
|
+
effects.consume(code);
|
|
203
|
+
|
|
204
|
+
return closer;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function closer(code: Code): State | undefined {
|
|
208
|
+
if (code !== close) return nok(code);
|
|
209
|
+
effects.consume(code);
|
|
210
|
+
|
|
211
|
+
return ownLine ? closingLine : closingEnd;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function closingLine(code: Code): State | undefined {
|
|
215
|
+
if (lineSpace(code)) {
|
|
216
|
+
effects.consume(code);
|
|
217
|
+
|
|
218
|
+
return closingLine;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return code === null || lineEnding(code) ? closingEnd(code) : nok(code);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function closingEnd(code: Code): State | undefined {
|
|
225
|
+
effects.exit("latexMathSequence");
|
|
226
|
+
|
|
227
|
+
return ok(code);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
2
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
3
|
+
import { mathFromMarkdown } from "mdast-util-math";
|
|
4
|
+
import { gfm } from "micromark-extension-gfm";
|
|
5
|
+
import { math } from "micromark-extension-math";
|
|
6
|
+
|
|
7
|
+
import { latexMath, latexMathFromMarkdown } from "./markdown-latex-math";
|
|
8
|
+
|
|
9
|
+
export interface MarkdownNode {
|
|
10
|
+
type: string;
|
|
11
|
+
position?: { start: { offset?: number }; end: { offset?: number } };
|
|
12
|
+
children?: MarkdownNode[];
|
|
13
|
+
value?: string;
|
|
14
|
+
depth?: number;
|
|
15
|
+
ordered?: boolean | null;
|
|
16
|
+
start?: number | null;
|
|
17
|
+
checked?: boolean | null;
|
|
18
|
+
lang?: string | null;
|
|
19
|
+
url?: string;
|
|
20
|
+
title?: string | null;
|
|
21
|
+
align?: Array<string | null> | null;
|
|
22
|
+
alt?: string | null;
|
|
23
|
+
identifier?: string;
|
|
24
|
+
label?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function* markdownNodes(node: MarkdownNode): Generator<MarkdownNode> {
|
|
28
|
+
yield node;
|
|
29
|
+
|
|
30
|
+
for (const child of node.children ?? []) yield* markdownNodes(child);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The text a node carries, its own and every descendant's. */
|
|
34
|
+
export function markdownText(node: MarkdownNode): string {
|
|
35
|
+
return [...markdownNodes(node)].map((child) => child.value ?? "").join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shared syntax for submit-time source checks and safe Markdown rendering. */
|
|
39
|
+
export function parseMarkdown(source: string): MarkdownNode {
|
|
40
|
+
const tree: MarkdownNode = fromMarkdown(source, {
|
|
41
|
+
extensions: [gfm(), math(), latexMath()],
|
|
42
|
+
mdastExtensions: [
|
|
43
|
+
gfmFromMarkdown(),
|
|
44
|
+
mathFromMarkdown(),
|
|
45
|
+
latexMathFromMarkdown(),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// `$$x$$` alone in its paragraph is a display equation written on one line.
|
|
50
|
+
for (const node of markdownNodes(tree)) {
|
|
51
|
+
const [only, ...others] = node.children ?? [];
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
node.type === "paragraph" &&
|
|
55
|
+
only?.type === "inlineMath" &&
|
|
56
|
+
others.length === 0 &&
|
|
57
|
+
source.startsWith("$$", only.position?.start.offset)
|
|
58
|
+
)
|
|
59
|
+
only.type = "math";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const definitions = new Map<string, MarkdownNode>();
|
|
63
|
+
|
|
64
|
+
for (const node of markdownNodes(tree))
|
|
65
|
+
if (node.type === "definition" && !definitions.has(node.identifier!))
|
|
66
|
+
definitions.set(node.identifier!, node);
|
|
67
|
+
|
|
68
|
+
for (const node of markdownNodes(tree)) {
|
|
69
|
+
if (node.type !== "linkReference") continue;
|
|
70
|
+
const definition = definitions.get(node.identifier!);
|
|
71
|
+
|
|
72
|
+
if (definition)
|
|
73
|
+
Object.assign(node, {
|
|
74
|
+
type: "link",
|
|
75
|
+
url: definition.url,
|
|
76
|
+
title: definition.title,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return tree;
|
|
81
|
+
}
|