oh-my-opencode 4.18.0 → 4.18.2
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/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3677 -0
- package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3154 -0
- package/.agents/skills/work-with-pr/SKILL.md +16 -37
- package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/.opencode/skills/work-with-pr/SKILL.md +16 -37
- package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/bin/AGENTS.md +33 -0
- package/dist/cli/index.js +500 -158
- package/dist/cli-node/index.js +500 -158
- package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
- package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
- package/dist/hooks/comment-checker/hook.d.ts +8 -1
- package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
- package/dist/index.js +956 -702
- package/dist/plugin/messages-transform.d.ts +1 -0
- package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
- package/dist/tui.js +43 -4
- package/package.json +16 -16
- package/packages/git-bash-mcp/dist/cli.js +81 -19
- package/packages/lsp-core/package.json +4 -0
- package/packages/lsp-core/src/index.ts +1 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
- package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
- package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
- package/packages/lsp-core/src/lsp/client.ts +262 -80
- package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
- package/packages/lsp-core/src/lsp/connection.ts +12 -6
- package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +221 -0
- package/packages/lsp-core/src/lsp/directory-diagnostics.ts +61 -28
- package/packages/lsp-core/src/lsp/errors.ts +11 -0
- package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
- package/packages/lsp-core/src/lsp/formatters.ts +3 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
- package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
- package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
- package/packages/lsp-core/src/lsp/transport.ts +96 -70
- package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
- package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
- package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
- package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
- package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
- package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
- package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
- package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
- package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
- package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
- package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
- package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
- package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
- package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
- package/packages/lsp-core/src/mcp.ts +18 -7
- package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
- package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
- package/packages/lsp-core/src/post-edit/index.ts +1 -0
- package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
- package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
- package/packages/lsp-core/src/request-context.test.ts +171 -0
- package/packages/lsp-core/src/request-context.ts +222 -9
- package/packages/lsp-core/src/tool-surface.test.ts +4 -1
- package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
- package/packages/lsp-core/src/tools/navigation.ts +12 -12
- package/packages/lsp-core/src/tools/rename.ts +10 -15
- package/packages/lsp-core/src/tools/symbols.ts +11 -11
- package/packages/lsp-core/src/tools/types.ts +2 -1
- package/packages/lsp-daemon/dist/cli.js +3330 -764
- package/packages/lsp-daemon/dist/client.d.ts +105 -0
- package/packages/lsp-daemon/dist/client.js +5995 -0
- package/packages/lsp-daemon/dist/daemon-client.d.ts +12 -7
- package/packages/lsp-daemon/dist/daemon-client.js +139 -32
- package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
- package/packages/lsp-daemon/dist/daemon-server.js +40 -15
- package/packages/lsp-daemon/dist/ensure-daemon.d.ts +10 -8
- package/packages/lsp-daemon/dist/ensure-daemon.js +135 -51
- package/packages/lsp-daemon/dist/index.d.ts +2 -2
- package/packages/lsp-daemon/dist/index.js +3093 -786
- package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
- package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
- package/packages/lsp-daemon/dist/lock.js +14 -4
- package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
- package/packages/lsp-daemon/dist/ownership.js +168 -0
- package/packages/lsp-daemon/dist/paths.d.ts +33 -9
- package/packages/lsp-daemon/dist/paths.js +72 -33
- package/packages/lsp-daemon/dist/proxy.d.ts +5 -0
- package/packages/lsp-daemon/dist/proxy.js +123 -16
- package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
- package/packages/lsp-daemon/dist/request-routing.js +71 -22
- package/packages/lsp-daemon/dist/run-daemon.js +9 -2
- package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
- package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
- package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
- package/packages/lsp-daemon/package.json +12 -3
- package/packages/lsp-tools-mcp/dist/cli.js +2189 -454
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
- package/packages/lsp-tools-mcp/dist/mcp.js +2206 -471
- package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
- package/packages/lsp-tools-mcp/dist/tools.js +2119 -447
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/.mcp.json +2 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +100 -28
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
- package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/.mcp.json +2 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +3033 -936
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
- package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
- package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
- package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +20 -5
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -3
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/README.md +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
- package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
- package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -2
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +26 -14
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
- package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +9 -1
- package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
- package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
- package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
- package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
- package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
- package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +34 -3
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { existsSync, lstatSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { snapshotPath } from "./workspace-edit-path.js";
|
|
4
|
+
import type {
|
|
5
|
+
ApplyResult,
|
|
6
|
+
ApplyWorkspaceEditOptions,
|
|
7
|
+
PlannedWorkspaceOperation,
|
|
8
|
+
WorkspaceEditCommit,
|
|
9
|
+
WorkspaceEditCommitIo,
|
|
10
|
+
WorkspaceEditPlan,
|
|
11
|
+
WorkspaceMutation,
|
|
12
|
+
WorkspaceMutationDelta,
|
|
13
|
+
WorkspaceSnapshotEntry,
|
|
14
|
+
} from "./workspace-edit-types.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_IO: WorkspaceEditCommitIo = {
|
|
17
|
+
writeFile(path, content) {
|
|
18
|
+
writeFileSync(path, content, "utf-8");
|
|
19
|
+
},
|
|
20
|
+
rename(oldPath, newPath) {
|
|
21
|
+
renameSync(oldPath, newPath);
|
|
22
|
+
},
|
|
23
|
+
remove(path, recursive) {
|
|
24
|
+
rmSync(path, { recursive, force: false });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function snapshotsEqual(expected: WorkspaceSnapshotEntry, actual: WorkspaceSnapshotEntry): boolean {
|
|
29
|
+
if (expected.kind !== actual.kind) return false;
|
|
30
|
+
if (expected.kind === "file" && actual.kind === "file") return expected.content === actual.content;
|
|
31
|
+
if (expected.kind === "directory" && actual.kind === "directory" && expected.children !== undefined) {
|
|
32
|
+
return JSON.stringify(expected.children) === JSON.stringify(actual.children);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function liveSnapshot(path: string, expected: WorkspaceSnapshotEntry): WorkspaceSnapshotEntry {
|
|
38
|
+
return snapshotPath(path, expected.kind === "directory" && expected.children !== undefined);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function firstOperationIndex(plan: WorkspaceEditPlan): number {
|
|
42
|
+
return plan.operations[0]?.changeIndex ?? 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CommitFailure {
|
|
46
|
+
readonly message: string;
|
|
47
|
+
readonly changeIndex: number;
|
|
48
|
+
readonly mutations?: readonly WorkspaceMutation[];
|
|
49
|
+
readonly filesModified?: readonly string[];
|
|
50
|
+
readonly totalEdits?: number;
|
|
51
|
+
readonly lateAbort?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function failedCommit(plan: WorkspaceEditPlan, failure: CommitFailure): WorkspaceEditCommit {
|
|
55
|
+
const { message, changeIndex, mutations = [], filesModified = [], totalEdits = 0, lateAbort = false } = failure;
|
|
56
|
+
return {
|
|
57
|
+
result: {
|
|
58
|
+
success: false,
|
|
59
|
+
filesModified,
|
|
60
|
+
totalEdits,
|
|
61
|
+
errors: [`change ${changeIndex}: ${message}`],
|
|
62
|
+
failedChange: changeIndex,
|
|
63
|
+
...(lateAbort ? { lateAbort: true } : {}),
|
|
64
|
+
},
|
|
65
|
+
delta: mutationDelta(mutations),
|
|
66
|
+
fingerprint: plan.fingerprint,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function verifySnapshots(plan: WorkspaceEditPlan): WorkspaceEditCommit | null {
|
|
71
|
+
for (const [path, expected] of plan.snapshots) {
|
|
72
|
+
let actual: WorkspaceSnapshotEntry;
|
|
73
|
+
try {
|
|
74
|
+
actual = liveSnapshot(path, expected);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const changeIndex = plan.firstChangeByPath.get(path) ?? firstOperationIndex(plan);
|
|
77
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
78
|
+
return failedCommit(plan, { message: `cannot verify snapshot for ${path}: ${detail}`, changeIndex });
|
|
79
|
+
}
|
|
80
|
+
if (!snapshotsEqual(expected, actual)) {
|
|
81
|
+
const changeIndex = plan.firstChangeByPath.get(path) ?? firstOperationIndex(plan);
|
|
82
|
+
return failedCommit(plan, { message: `workspace state changed before commit: ${path}`, changeIndex });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function addModifiedPath(paths: string[], path: string): void {
|
|
89
|
+
if (!paths.includes(path)) paths.push(path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function reportedPath(plan: WorkspaceEditPlan, path: string): string {
|
|
93
|
+
return plan.reportedPathByCanonical.get(path) ?? path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function changedPathsForMutation(mutation: WorkspaceMutation): readonly string[] {
|
|
97
|
+
return mutation.kind === "rename" ? [mutation.oldPath, mutation.newPath] : [mutation.path];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mutationDelta(operations: readonly WorkspaceMutation[]): WorkspaceMutationDelta {
|
|
101
|
+
const changedPaths = new Set<string>();
|
|
102
|
+
for (const operation of operations) {
|
|
103
|
+
for (const path of changedPathsForMutation(operation)) changedPaths.add(path);
|
|
104
|
+
}
|
|
105
|
+
return { operations, changedPaths: [...changedPaths].sort() };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveIo(overrides: Partial<WorkspaceEditCommitIo> | undefined): WorkspaceEditCommitIo {
|
|
109
|
+
return {
|
|
110
|
+
writeFile: overrides?.writeFile ?? DEFAULT_IO.writeFile,
|
|
111
|
+
rename: overrides?.rename ?? DEFAULT_IO.rename,
|
|
112
|
+
remove: overrides?.remove ?? DEFAULT_IO.remove,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface CommitAccumulator {
|
|
117
|
+
readonly mutations: WorkspaceMutation[];
|
|
118
|
+
readonly filesModified: string[];
|
|
119
|
+
totalEdits: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface CommitContext {
|
|
123
|
+
readonly plan: WorkspaceEditPlan;
|
|
124
|
+
readonly io: WorkspaceEditCommitIo;
|
|
125
|
+
readonly accumulator: CommitAccumulator;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function commitOperation(
|
|
129
|
+
context: CommitContext,
|
|
130
|
+
operation: PlannedWorkspaceOperation,
|
|
131
|
+
): void {
|
|
132
|
+
const { plan, io, accumulator } = context;
|
|
133
|
+
if (operation.kind === "noop") return;
|
|
134
|
+
if (operation.kind === "text") {
|
|
135
|
+
io.writeFile(operation.path, operation.afterText);
|
|
136
|
+
accumulator.mutations.push({
|
|
137
|
+
kind: "text",
|
|
138
|
+
path: operation.path,
|
|
139
|
+
beforeText: operation.beforeText,
|
|
140
|
+
afterText: operation.afterText,
|
|
141
|
+
});
|
|
142
|
+
addModifiedPath(accumulator.filesModified, reportedPath(plan, operation.path));
|
|
143
|
+
accumulator.totalEdits += operation.editCount;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (operation.kind === "create") {
|
|
147
|
+
io.writeFile(operation.path, "");
|
|
148
|
+
accumulator.mutations.push({ kind: "create", path: operation.path, replaced: operation.replaced });
|
|
149
|
+
addModifiedPath(accumulator.filesModified, reportedPath(plan, operation.path));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (operation.kind === "rename") {
|
|
153
|
+
if (operation.replaceDestination) {
|
|
154
|
+
const targetKind = existsSync(operation.newPath) && lstatSync(operation.newPath).isDirectory() ? "directory" : "file";
|
|
155
|
+
io.remove(operation.newPath, targetKind === "directory");
|
|
156
|
+
accumulator.mutations.push({ kind: "delete", path: operation.newPath, targetKind });
|
|
157
|
+
addModifiedPath(accumulator.filesModified, reportedPath(plan, operation.newPath));
|
|
158
|
+
}
|
|
159
|
+
io.rename(operation.oldPath, operation.newPath);
|
|
160
|
+
accumulator.mutations.push({
|
|
161
|
+
kind: "rename",
|
|
162
|
+
oldPath: operation.oldPath,
|
|
163
|
+
newPath: operation.newPath,
|
|
164
|
+
sourceKind: operation.sourceKind,
|
|
165
|
+
});
|
|
166
|
+
addModifiedPath(accumulator.filesModified, reportedPath(plan, operation.newPath));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
io.remove(operation.path, operation.recursive);
|
|
170
|
+
accumulator.mutations.push({
|
|
171
|
+
kind: "delete",
|
|
172
|
+
path: operation.path,
|
|
173
|
+
targetKind: operation.targetKind,
|
|
174
|
+
});
|
|
175
|
+
addModifiedPath(accumulator.filesModified, reportedPath(plan, operation.path));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function commitWorkspaceEditPlan(
|
|
179
|
+
plan: WorkspaceEditPlan,
|
|
180
|
+
options: Pick<ApplyWorkspaceEditOptions, "signal" | "io"> = {},
|
|
181
|
+
): WorkspaceEditCommit {
|
|
182
|
+
if (options.signal?.aborted) {
|
|
183
|
+
return failedCommit(plan, { message: "cancelled before commit", changeIndex: firstOperationIndex(plan) });
|
|
184
|
+
}
|
|
185
|
+
const stale = verifySnapshots(plan);
|
|
186
|
+
if (stale) return stale;
|
|
187
|
+
if (options.signal?.aborted) {
|
|
188
|
+
return failedCommit(plan, { message: "cancelled before commit", changeIndex: firstOperationIndex(plan) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const io = resolveIo(options.io);
|
|
192
|
+
const accumulator: CommitAccumulator = { mutations: [], filesModified: [], totalEdits: 0 };
|
|
193
|
+
const context: CommitContext = { plan, io, accumulator };
|
|
194
|
+
let lateAbort = false;
|
|
195
|
+
for (const operation of plan.operations) {
|
|
196
|
+
try {
|
|
197
|
+
commitOperation(context, operation);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
200
|
+
return failedCommit(plan, {
|
|
201
|
+
message: `I/O failure during ${operation.kind}: ${detail}`,
|
|
202
|
+
changeIndex: operation.changeIndex,
|
|
203
|
+
mutations: accumulator.mutations,
|
|
204
|
+
filesModified: accumulator.filesModified,
|
|
205
|
+
totalEdits: accumulator.totalEdits,
|
|
206
|
+
lateAbort: lateAbort || options.signal?.aborted === true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (options.signal?.aborted) lateAbort = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result: ApplyResult = {
|
|
213
|
+
success: true,
|
|
214
|
+
filesModified: accumulator.filesModified,
|
|
215
|
+
totalEdits: accumulator.totalEdits,
|
|
216
|
+
errors: [],
|
|
217
|
+
...(lateAbort ? { lateAbort: true } : {}),
|
|
218
|
+
};
|
|
219
|
+
return { result, delta: mutationDelta(accumulator.mutations), fingerprint: plan.fingerprint };
|
|
220
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CANONICAL_CONCURRENT_WORKSPACE_APPLY_EDIT_FAILURE_REASON,
|
|
5
|
+
workspaceApplyEditConcurrentFailureReason,
|
|
6
|
+
} from "./workspace-apply-edit-failure.js";
|
|
7
|
+
import {
|
|
8
|
+
normalizeWorkspaceEditContractEvidence,
|
|
9
|
+
workspaceEditContractFailureEvidence,
|
|
10
|
+
} from "./workspace-edit-contract-evidence.js";
|
|
11
|
+
|
|
12
|
+
describe("workspace edit contract evidence", () => {
|
|
13
|
+
it("#given both legitimate concurrent applyEdit lease outcomes #when failure evidence is hashed #then one canonical shape and hash is produced", () => {
|
|
14
|
+
const workspace = "/tmp/workspace-edit-contract";
|
|
15
|
+
const applying = workspaceEditContractFailureEvidence(
|
|
16
|
+
{
|
|
17
|
+
applied: false,
|
|
18
|
+
failureReason: workspaceApplyEditConcurrentFailureReason("applying"),
|
|
19
|
+
},
|
|
20
|
+
workspace,
|
|
21
|
+
);
|
|
22
|
+
const settled = workspaceEditContractFailureEvidence(
|
|
23
|
+
{
|
|
24
|
+
applied: false,
|
|
25
|
+
failureReason: workspaceApplyEditConcurrentFailureReason("settled"),
|
|
26
|
+
},
|
|
27
|
+
workspace,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(applying.normalized).toEqual({
|
|
31
|
+
applied: false,
|
|
32
|
+
failureReason: CANONICAL_CONCURRENT_WORKSPACE_APPLY_EDIT_FAILURE_REASON,
|
|
33
|
+
});
|
|
34
|
+
expect(settled.normalized).toEqual(applying.normalized);
|
|
35
|
+
expect(settled.sha256).toBe(applying.sha256);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("#given workspace-scoped evidence and a non-concurrent failure #when normalized #then paths are sanitized and the reason is preserved", () => {
|
|
39
|
+
const workspace = "/tmp/workspace-edit-contract";
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
normalizeWorkspaceEditContractEvidence(
|
|
43
|
+
{
|
|
44
|
+
applied: false,
|
|
45
|
+
failureReason: "document version 9 does not match open document version 1 for /tmp/workspace-edit-contract/source.ts",
|
|
46
|
+
nested: ["/tmp/workspace-edit-contract/source.ts"],
|
|
47
|
+
},
|
|
48
|
+
workspace,
|
|
49
|
+
),
|
|
50
|
+
).toEqual({
|
|
51
|
+
applied: false,
|
|
52
|
+
failureReason: "document version 9 does not match open document version 1 for <workspace>/source.ts",
|
|
53
|
+
nested: ["<workspace>/source.ts"],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { canonicalizeWorkspaceApplyEditFailureReason } from "./workspace-apply-edit-failure.js";
|
|
4
|
+
|
|
5
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeWorkspaceEditContractEvidence(value: unknown, workspace: string): unknown {
|
|
10
|
+
if (typeof value === "string") return value.replaceAll(workspace, "<workspace>");
|
|
11
|
+
if (Array.isArray(value)) return value.map((entry) => normalizeWorkspaceEditContractEvidence(entry, workspace));
|
|
12
|
+
if (!isRecord(value)) return value;
|
|
13
|
+
const normalized: Record<string, unknown> = {};
|
|
14
|
+
for (const key of Object.keys(value).sort()) {
|
|
15
|
+
const entry = normalizeWorkspaceEditContractEvidence(value[key], workspace);
|
|
16
|
+
normalized[key] =
|
|
17
|
+
key === "failureReason" && typeof entry === "string"
|
|
18
|
+
? canonicalizeWorkspaceApplyEditFailureReason(entry)
|
|
19
|
+
: entry;
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function workspaceEditContractFailureEvidence(value: unknown, workspace: string) {
|
|
25
|
+
const normalized = normalizeWorkspaceEditContractEvidence(value, workspace);
|
|
26
|
+
return {
|
|
27
|
+
normalized,
|
|
28
|
+
sha256: createHash("sha256").update(JSON.stringify(normalized)).digest("hex"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ParsedWorkspaceOperation } from "./workspace-edit-plan-types.js";
|
|
4
|
+
|
|
5
|
+
export function canonicalFingerprint(operations: readonly ParsedWorkspaceOperation[]): string {
|
|
6
|
+
const canonical = operations.map((operation) => {
|
|
7
|
+
switch (operation.kind) {
|
|
8
|
+
case "text":
|
|
9
|
+
return {
|
|
10
|
+
kind: operation.kind,
|
|
11
|
+
changeIndex: operation.changeIndex,
|
|
12
|
+
path: operation.path,
|
|
13
|
+
edits: operation.edits,
|
|
14
|
+
version: operation.version,
|
|
15
|
+
};
|
|
16
|
+
case "rename":
|
|
17
|
+
return {
|
|
18
|
+
kind: operation.kind,
|
|
19
|
+
changeIndex: operation.changeIndex,
|
|
20
|
+
oldPath: operation.oldPath,
|
|
21
|
+
newPath: operation.newPath,
|
|
22
|
+
overwrite: operation.overwrite,
|
|
23
|
+
ignoreIfExists: operation.ignoreIfExists,
|
|
24
|
+
};
|
|
25
|
+
case "create":
|
|
26
|
+
return {
|
|
27
|
+
kind: operation.kind,
|
|
28
|
+
changeIndex: operation.changeIndex,
|
|
29
|
+
path: operation.path,
|
|
30
|
+
overwrite: operation.overwrite,
|
|
31
|
+
ignoreIfExists: operation.ignoreIfExists,
|
|
32
|
+
};
|
|
33
|
+
case "delete":
|
|
34
|
+
return {
|
|
35
|
+
kind: operation.kind,
|
|
36
|
+
changeIndex: operation.changeIndex,
|
|
37
|
+
path: operation.path,
|
|
38
|
+
recursive: operation.recursive,
|
|
39
|
+
ignoreIfNotExists: operation.ignoreIfNotExists,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
44
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { applyWorkspaceEdit } from "./workspace-edit.js";
|
|
9
|
+
|
|
10
|
+
const tempDirectories: string[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("applyWorkspaceEdit resource options", () => {
|
|
19
|
+
it("#given create ignoreIfExists #when the target exists #then existing bytes are preserved", () => {
|
|
20
|
+
// given
|
|
21
|
+
const workspace = makeTempDirectory();
|
|
22
|
+
const target = join(workspace, "target.ts");
|
|
23
|
+
writeFileSync(target, "preserve\n", "utf-8");
|
|
24
|
+
|
|
25
|
+
// when
|
|
26
|
+
const result = applyWorkspaceEdit(
|
|
27
|
+
{
|
|
28
|
+
documentChanges: [
|
|
29
|
+
{ kind: "create", uri: pathToFileURL(target).href, options: { ignoreIfExists: true } },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ workspaceRoot: workspace },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// then
|
|
36
|
+
expect(result.success).toBe(true);
|
|
37
|
+
expect(result.filesModified).toEqual([]);
|
|
38
|
+
expect(readFileSync(target, "utf-8")).toBe("preserve\n");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("#given rename ignoreIfExists #when the destination exists #then both files are preserved", () => {
|
|
42
|
+
// given
|
|
43
|
+
const workspace = makeTempDirectory();
|
|
44
|
+
const source = join(workspace, "source.ts");
|
|
45
|
+
const destination = join(workspace, "destination.ts");
|
|
46
|
+
writeFileSync(source, "source\n", "utf-8");
|
|
47
|
+
writeFileSync(destination, "destination\n", "utf-8");
|
|
48
|
+
|
|
49
|
+
// when
|
|
50
|
+
const result = applyWorkspaceEdit(
|
|
51
|
+
{
|
|
52
|
+
documentChanges: [
|
|
53
|
+
{
|
|
54
|
+
kind: "rename",
|
|
55
|
+
oldUri: pathToFileURL(source).href,
|
|
56
|
+
newUri: pathToFileURL(destination).href,
|
|
57
|
+
options: { ignoreIfExists: true },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{ workspaceRoot: workspace },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// then
|
|
65
|
+
expect(result.success).toBe(true);
|
|
66
|
+
expect(result.filesModified).toEqual([]);
|
|
67
|
+
expect(readFileSync(source, "utf-8")).toBe("source\n");
|
|
68
|
+
expect(readFileSync(destination, "utf-8")).toBe("destination\n");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("#given rename overwrite #when the destination exists #then source replaces destination", () => {
|
|
72
|
+
// given
|
|
73
|
+
const workspace = makeTempDirectory();
|
|
74
|
+
const source = join(workspace, "source.ts");
|
|
75
|
+
const destination = join(workspace, "destination.ts");
|
|
76
|
+
writeFileSync(source, "source\n", "utf-8");
|
|
77
|
+
writeFileSync(destination, "destination\n", "utf-8");
|
|
78
|
+
|
|
79
|
+
// when
|
|
80
|
+
const result = applyWorkspaceEdit(
|
|
81
|
+
{
|
|
82
|
+
documentChanges: [
|
|
83
|
+
{
|
|
84
|
+
kind: "rename",
|
|
85
|
+
oldUri: pathToFileURL(source).href,
|
|
86
|
+
newUri: pathToFileURL(destination).href,
|
|
87
|
+
options: { overwrite: true },
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{ workspaceRoot: workspace },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// then
|
|
95
|
+
expect(result.success).toBe(true);
|
|
96
|
+
expect(existsSync(source)).toBe(false);
|
|
97
|
+
expect(readFileSync(destination, "utf-8")).toBe("source\n");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("#given delete ignoreIfNotExists #when the target is missing #then it is a successful no-op", () => {
|
|
101
|
+
// given
|
|
102
|
+
const workspace = makeTempDirectory();
|
|
103
|
+
const target = join(workspace, "missing.ts");
|
|
104
|
+
|
|
105
|
+
// when
|
|
106
|
+
const result = applyWorkspaceEdit(
|
|
107
|
+
{
|
|
108
|
+
documentChanges: [
|
|
109
|
+
{ kind: "delete", uri: pathToFileURL(target).href, options: { ignoreIfNotExists: true } },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{ workspaceRoot: workspace },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// then
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
expect(result.filesModified).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("#given recursive delete #when the target is a non-empty directory #then the subtree is removed", () => {
|
|
121
|
+
// given
|
|
122
|
+
const workspace = makeTempDirectory();
|
|
123
|
+
const target = join(workspace, "nested");
|
|
124
|
+
mkdirSync(target);
|
|
125
|
+
writeFileSync(join(target, "child.ts"), "child\n", "utf-8");
|
|
126
|
+
|
|
127
|
+
// when
|
|
128
|
+
const result = applyWorkspaceEdit(
|
|
129
|
+
{
|
|
130
|
+
documentChanges: [
|
|
131
|
+
{ kind: "delete", uri: pathToFileURL(target).href, options: { recursive: true } },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{ workspaceRoot: workspace },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// then
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
expect(existsSync(target)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function makeTempDirectory(): string {
|
|
144
|
+
const directory = mkdtempSync(join(tmpdir(), "lsp-workspace-options-"));
|
|
145
|
+
tempDirectories.push(directory);
|
|
146
|
+
return directory;
|
|
147
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Position, Range, TextEdit } from "./types.js";
|
|
2
|
+
import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
|
|
3
|
+
|
|
4
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parsePosition(value: unknown): Position | null {
|
|
9
|
+
if (!isRecord(value) || typeof value["line"] !== "number" || typeof value["character"] !== "number") {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return { line: value["line"], character: value["character"] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseRange(value: unknown): Range | null {
|
|
16
|
+
if (!isRecord(value)) return null;
|
|
17
|
+
const start = parsePosition(value["start"]);
|
|
18
|
+
const end = parsePosition(value["end"]);
|
|
19
|
+
return start && end ? { start, end } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseTextEdits(value: unknown, changeIndex: number): readonly TextEdit[] {
|
|
23
|
+
if (!Array.isArray(value)) {
|
|
24
|
+
throw new WorkspaceEditValidationError(changeIndex, "text edits must be an array");
|
|
25
|
+
}
|
|
26
|
+
const edits: TextEdit[] = [];
|
|
27
|
+
for (const candidate of value) {
|
|
28
|
+
if (!isRecord(candidate) || typeof candidate["newText"] !== "string") {
|
|
29
|
+
throw new WorkspaceEditValidationError(changeIndex, "text edit requires range and newText");
|
|
30
|
+
}
|
|
31
|
+
if ("annotationId" in candidate) {
|
|
32
|
+
throw new WorkspaceEditValidationError(changeIndex, "annotated text edits are unsupported");
|
|
33
|
+
}
|
|
34
|
+
const range = parseRange(candidate["range"]);
|
|
35
|
+
if (!range) throw new WorkspaceEditValidationError(changeIndex, "text edit range is malformed");
|
|
36
|
+
edits.push({ range, newText: candidate["newText"] });
|
|
37
|
+
}
|
|
38
|
+
return edits;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseBooleanOption(options: Record<string, unknown>, key: string, changeIndex: number): boolean {
|
|
42
|
+
const value = options[key];
|
|
43
|
+
if (value === undefined) return false;
|
|
44
|
+
if (typeof value !== "boolean") {
|
|
45
|
+
throw new WorkspaceEditValidationError(changeIndex, `${key} must be boolean`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseOptions(value: unknown, allowed: readonly string[], changeIndex: number): Record<string, boolean> {
|
|
51
|
+
if (value === undefined) return {};
|
|
52
|
+
if (!isRecord(value)) throw new WorkspaceEditValidationError(changeIndex, "resource options must be an object");
|
|
53
|
+
for (const key of Object.keys(value)) {
|
|
54
|
+
if (!allowed.includes(key)) throw new WorkspaceEditValidationError(changeIndex, `unsupported resource option ${key}`);
|
|
55
|
+
}
|
|
56
|
+
const parsed: Record<string, boolean> = {};
|
|
57
|
+
for (const key of allowed) parsed[key] = parseBooleanOption(value, key, changeIndex);
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { uriToCanonicalWorkspacePath } from "./workspace-edit-path.js";
|
|
2
|
+
import { isRecord, parseTextEdits } from "./workspace-edit-parse-helpers.js";
|
|
3
|
+
import type { ParsedWorkspaceEdit, ParsedWorkspaceOperation, ParseFailure } from "./workspace-edit-plan-types.js";
|
|
4
|
+
import { parseResourceChange } from "./workspace-edit-resource-parser.js";
|
|
5
|
+
import type { ApplyResult } from "./workspace-edit-types.js";
|
|
6
|
+
import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
|
|
7
|
+
|
|
8
|
+
interface ParseTarget {
|
|
9
|
+
readonly operations: ParsedWorkspaceOperation[];
|
|
10
|
+
readonly failures: ParseFailure[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DocumentChangeInput {
|
|
14
|
+
readonly change: unknown;
|
|
15
|
+
readonly changeIndex: number;
|
|
16
|
+
readonly workspaceRoot: string;
|
|
17
|
+
readonly target: ParseTarget;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function failureResult(failures: readonly ParseFailure[]): ApplyResult {
|
|
21
|
+
const sorted = [...failures].sort((left, right) => left.changeIndex - right.changeIndex);
|
|
22
|
+
const first = sorted[0];
|
|
23
|
+
return {
|
|
24
|
+
success: false,
|
|
25
|
+
filesModified: [],
|
|
26
|
+
totalEdits: 0,
|
|
27
|
+
errors: sorted.map((failure) => `change ${failure.changeIndex}: ${failure.message}`),
|
|
28
|
+
...(first ? { failedChange: first.changeIndex } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseWorkspaceEdit(edit: unknown, workspaceRoot: string): ParsedWorkspaceEdit {
|
|
33
|
+
if (!isRecord(edit)) return { operations: [], failures: [{ changeIndex: 0, message: "No edit provided" }] };
|
|
34
|
+
if (edit["changeAnnotations"] !== undefined) {
|
|
35
|
+
return { operations: [], failures: [{ changeIndex: 0, message: "change annotations are unsupported" }] };
|
|
36
|
+
}
|
|
37
|
+
const hasChanges = edit["changes"] !== undefined;
|
|
38
|
+
const hasDocumentChanges = edit["documentChanges"] !== undefined;
|
|
39
|
+
if (hasChanges && hasDocumentChanges) {
|
|
40
|
+
return {
|
|
41
|
+
operations: [],
|
|
42
|
+
failures: [{ changeIndex: 0, message: "changes and documentChanges cannot be combined" }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const target: ParseTarget = { operations: [], failures: [] };
|
|
47
|
+
if (hasChanges) return parseChanges(edit["changes"], workspaceRoot, target);
|
|
48
|
+
return parseDocumentChanges(edit["documentChanges"], workspaceRoot, target);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseChanges(value: unknown, workspaceRoot: string, target: ParseTarget): ParsedWorkspaceEdit {
|
|
52
|
+
if (!isRecord(value)) return { ...target, failures: [{ changeIndex: 0, message: "changes must be an object" }] };
|
|
53
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
54
|
+
for (const [changeIndex, [uri, rawEdits]] of entries.entries()) {
|
|
55
|
+
const resolvedPath = uriToCanonicalWorkspacePath(uri, workspaceRoot);
|
|
56
|
+
if (!resolvedPath.success) {
|
|
57
|
+
target.failures.push({ changeIndex, message: resolvedPath.error });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
target.operations.push({
|
|
62
|
+
kind: "text",
|
|
63
|
+
changeIndex,
|
|
64
|
+
path: resolvedPath.path,
|
|
65
|
+
reportedPath: resolvedPath.requestedPath,
|
|
66
|
+
edits: parseTextEdits(rawEdits, changeIndex),
|
|
67
|
+
version: null,
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof WorkspaceEditValidationError) {
|
|
71
|
+
target.failures.push({ changeIndex, message: error.detail });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseDocumentChanges(value: unknown, workspaceRoot: string, target: ParseTarget): ParsedWorkspaceEdit {
|
|
81
|
+
if (value === undefined) return target;
|
|
82
|
+
if (!Array.isArray(value)) {
|
|
83
|
+
return { ...target, failures: [{ changeIndex: 0, message: "documentChanges must be an array" }] };
|
|
84
|
+
}
|
|
85
|
+
for (const [changeIndex, change] of value.entries()) {
|
|
86
|
+
try {
|
|
87
|
+
parseDocumentChange({ change, changeIndex, workspaceRoot, target });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof WorkspaceEditValidationError) {
|
|
90
|
+
target.failures.push({ changeIndex, message: error.detail });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return target;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseDocumentChange(input: DocumentChangeInput): void {
|
|
100
|
+
const { change, changeIndex, workspaceRoot, target } = input;
|
|
101
|
+
if (!isRecord(change)) throw new WorkspaceEditValidationError(changeIndex, "document change must be an object");
|
|
102
|
+
if ("annotationId" in change) {
|
|
103
|
+
throw new WorkspaceEditValidationError(changeIndex, "annotated resource operations are unsupported");
|
|
104
|
+
}
|
|
105
|
+
if (typeof change["kind"] === "string") {
|
|
106
|
+
parseResourceChange({ change, changeIndex, workspaceRoot, target });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const identifier = change["textDocument"];
|
|
110
|
+
if (!isRecord(identifier) || typeof identifier["uri"] !== "string") {
|
|
111
|
+
throw new WorkspaceEditValidationError(changeIndex, "textDocument.uri is required");
|
|
112
|
+
}
|
|
113
|
+
const version = identifier["version"];
|
|
114
|
+
if (version !== null && (!Number.isInteger(version) || typeof version !== "number" || version < 0)) {
|
|
115
|
+
throw new WorkspaceEditValidationError(changeIndex, "document version must be null or a non-negative integer");
|
|
116
|
+
}
|
|
117
|
+
const resolvedPath = uriToCanonicalWorkspacePath(identifier["uri"], workspaceRoot);
|
|
118
|
+
if (!resolvedPath.success) {
|
|
119
|
+
target.failures.push({ changeIndex, message: resolvedPath.error });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
target.operations.push({
|
|
123
|
+
kind: "text",
|
|
124
|
+
changeIndex,
|
|
125
|
+
path: resolvedPath.path,
|
|
126
|
+
reportedPath: resolvedPath.requestedPath,
|
|
127
|
+
edits: parseTextEdits(change["edits"], changeIndex),
|
|
128
|
+
version,
|
|
129
|
+
});
|
|
130
|
+
}
|