@cspeach/cli 0.6.4 → 0.7.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 (153) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/index.js +0 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +39 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +6 -1
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +86 -82
  49. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  50. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  51. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  52. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  53. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  54. package/dist/agent/__tests__/retry-key.test.js +0 -77
  55. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  56. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  57. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  58. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  59. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  60. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  61. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  62. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  63. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  64. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  65. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  66. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  67. package/dist/auth/__tests__/auth-file.test.js +0 -42
  68. package/dist/auth/__tests__/me.test.js +0 -43
  69. package/dist/classifier/__tests__/client.test.js +0 -70
  70. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  71. package/dist/commands/__tests__/login.test.js +0 -152
  72. package/dist/commands/__tests__/logout.test.js +0 -39
  73. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  74. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  75. package/dist/commands/__tests__/whoami.test.js +0 -91
  76. package/dist/config/__tests__/llm-config.test.js +0 -28
  77. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  78. package/dist/config/__tests__/write-mode.test.js +0 -36
  79. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  80. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  81. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  82. package/dist/project-context/__tests__/conventions.test.js +0 -221
  83. package/dist/project-context/__tests__/detect.test.js +0 -177
  84. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  85. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  86. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  87. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  88. package/dist/project-context/__tests__/git.test.js +0 -131
  89. package/dist/project-context/__tests__/index-files.test.js +0 -195
  90. package/dist/project-context/__tests__/index.test.js +0 -133
  91. package/dist/project-context/__tests__/render.test.js +0 -322
  92. package/dist/project-context/__tests__/types.test.js +0 -73
  93. package/dist/projects/__tests__/build.test.js +0 -52
  94. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  95. package/dist/projects/__tests__/email-template.test.js +0 -71
  96. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  97. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  98. package/dist/projects/__tests__/extract-design.test.js +0 -62
  99. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  100. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  101. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  102. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  103. package/dist/projects/__tests__/filename.test.js +0 -32
  104. package/dist/projects/__tests__/promote-command.test.js +0 -170
  105. package/dist/projects/__tests__/promote.test.js +0 -181
  106. package/dist/projects/__tests__/save-command.test.js +0 -216
  107. package/dist/projects/__tests__/save.test.js +0 -41
  108. package/dist/projects/__tests__/status.test.js +0 -208
  109. package/dist/projects/__tests__/types.test.js +0 -41
  110. package/dist/projects/__tests__/validate.test.js +0 -165
  111. package/dist/projects/__tests__/workspace.test.js +0 -337
  112. package/dist/repl/__tests__/current-transport.test.js +0 -43
  113. package/dist/repl/__tests__/diff-display.test.js +0 -59
  114. package/dist/repl/__tests__/file-picker.test.js +0 -97
  115. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  116. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  117. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  118. package/dist/sap/__tests__/system-info.test.js +0 -344
  119. package/dist/session/__tests__/store-discovery.test.js +0 -114
  120. package/dist/session/__tests__/time-ago.test.js +0 -53
  121. package/dist/skills/__tests__/canonical.test.js +0 -20
  122. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  123. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  124. package/dist/skills/__tests__/source-managed.test.js +0 -60
  125. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  126. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  127. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  128. package/dist/tools/__tests__/_flag.test.js +0 -66
  129. package/dist/tools/__tests__/_helpers.js +0 -17
  130. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  131. package/dist/tools/__tests__/agent_run.test.js +0 -166
  132. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  133. package/dist/tools/__tests__/background_run.test.js +0 -113
  134. package/dist/tools/__tests__/convention_get.test.js +0 -53
  135. package/dist/tools/__tests__/file-edit.test.js +0 -72
  136. package/dist/tools/__tests__/file-read.test.js +0 -81
  137. package/dist/tools/__tests__/file-write.test.js +0 -68
  138. package/dist/tools/__tests__/glob.test.js +0 -128
  139. package/dist/tools/__tests__/grep.test.js +0 -71
  140. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  141. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  142. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  143. package/dist/tools/__tests__/registry-category.test.js +0 -60
  144. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  145. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  146. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  147. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  148. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  149. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  150. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  151. package/dist/tools/__tests__/web_search.test.js +0 -215
  152. package/dist/tools/__tests__/write-mode.test.js +0 -32
  153. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,135 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { shellExecHandler } from '../shell/shell_exec.js';
6
- import { makeCtx } from './_helpers.js';
7
- let root;
8
- const ctx = () => makeCtx(root);
9
- beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-shell-')); });
10
- afterEach(async () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); });
11
- describe('shell_exec handler', () => {
12
- it('runs a safelisted command and surfaces stdout + exit code', async () => {
13
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write("hello")'] }, ctx());
14
- expect(r.is_error).toBeFalsy();
15
- expect(r.content).toMatch(/stdout:\s*hello/);
16
- expect(r.content).toMatch(/exit code:\s*0/);
17
- });
18
- it('surfaces stderr separately from stdout', async () => {
19
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stderr.write("err"); process.stdout.write("out")'] }, ctx());
20
- expect(r.content).toMatch(/stdout:\s*out/);
21
- expect(r.content).toMatch(/stderr:\s*err/);
22
- });
23
- it('reports non-zero exit code without marking is_error', async () => {
24
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.exit(42)'] }, ctx());
25
- // Non-zero exit is a normal program outcome — the agent should see it
26
- // and decide. is_error is reserved for "we refused / spawn failed".
27
- expect(r.is_error).toBeFalsy();
28
- expect(r.content).toMatch(/exit code:\s*42/);
29
- });
30
- it('shell metacharacters in args are passed literally — no shell parser', async () => {
31
- // The string `; rm -rf /` becomes literal argv[1] to node, which echoes it.
32
- // If a shell were interpreting, the `;` would split commands and the test
33
- // output would be different (or the rm command would attempt to run).
34
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write(process.argv[1])', '; rm -rf /'] }, ctx());
35
- expect(r.content).toMatch(/stdout:\s*; rm -rf \//);
36
- expect(r.content).toMatch(/exit code:\s*0/);
37
- });
38
- it('refuses an absolute-path command even if its basename is safelisted', async () => {
39
- const fakeAbsolute = process.platform === 'win32' ? 'C:\\Windows\\System32\\cmd.exe' : '/bin/sh';
40
- const r = await shellExecHandler({ command: fakeAbsolute, args: [] }, ctx());
41
- expect(r.is_error).toBe(true);
42
- expect(r.content).toMatch(/safelist|absolute path/i);
43
- });
44
- it('refuses a non-safelisted command', async () => {
45
- const r = await shellExecHandler({ command: 'curl', args: ['https://example.com'] }, ctx());
46
- expect(r.is_error).toBe(true);
47
- expect(r.content).toMatch(/safelist/i);
48
- expect(r.content).toMatch(/curl/);
49
- });
50
- it('refuses a cwd outside the project root', async () => {
51
- const outsideAbsolute = process.platform === 'win32' ? 'C:\\' : '/';
52
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write(process.cwd())'], cwd: outsideAbsolute }, ctx());
53
- expect(r.is_error).toBe(true);
54
- expect(r.content).toMatch(/outside|cwd/i);
55
- });
56
- it('does NOT pass auth-secret env vars (CSPEACH_*, ANTHROPIC_*, SAP_*) to the child', async () => {
57
- process.env.CSPEACH_TOKEN_TEST = 'must-not-leak';
58
- process.env.ANTHROPIC_API_KEY_TEST = 'must-not-leak';
59
- try {
60
- const r = await shellExecHandler({
61
- command: 'node',
62
- args: [
63
- '-e',
64
- 'const k = ["CSPEACH_TOKEN_TEST","ANTHROPIC_API_KEY_TEST"].filter(x=>process.env[x]); process.stdout.write(k.join(","))',
65
- ],
66
- }, ctx());
67
- expect(r.content).toMatch(/stdout:\s*\n/); // empty stdout — neither var visible to child
68
- expect(r.content).not.toContain('must-not-leak');
69
- }
70
- finally {
71
- delete process.env.CSPEACH_TOKEN_TEST;
72
- delete process.env.ANTHROPIC_API_KEY_TEST;
73
- }
74
- });
75
- it('kills a runaway child after the wall-clock timeout', async () => {
76
- const start = Date.now();
77
- const r = await shellExecHandler({ command: 'node', args: ['-e', 'setInterval(()=>{},1000)'], timeout_ms: 300 }, ctx());
78
- const elapsed = Date.now() - start;
79
- expect(r.is_error).toBe(true);
80
- expect(r.content).toMatch(/timeout|timed out/i);
81
- expect(elapsed).toBeLessThan(3_000); // generous — must be well under the 30s default
82
- });
83
- it('truncates stdout when it exceeds the per-stream cap', async () => {
84
- // 2 MB of A's — should be truncated at 1 MB with a marker.
85
- const r = await shellExecHandler({
86
- command: 'node',
87
- args: ['-e', 'process.stdout.write("A".repeat(2_000_000))'],
88
- timeout_ms: 10_000,
89
- }, ctx());
90
- expect(r.is_error).toBeFalsy();
91
- expect(r.content).toMatch(/\[truncated/i);
92
- // Content should not contain the full 2 MB — be conservative, check length cap.
93
- expect(r.content.length).toBeLessThan(1_200_000);
94
- });
95
- it('reports a structured error when the safelisted binary is not on PATH', async () => {
96
- const origPath = process.env.PATH;
97
- process.env.PATH = root; // PATH points only at empty tempdir — nothing installed.
98
- try {
99
- const r = await shellExecHandler({ command: 'abaplint', args: ['--version'] }, ctx());
100
- expect(r.is_error).toBe(true);
101
- expect(r.content).toMatch(/not installed|not found/i);
102
- expect(r.content).toMatch(/abaplint/);
103
- }
104
- finally {
105
- if (origPath === undefined)
106
- delete process.env.PATH;
107
- else
108
- process.env.PATH = origPath;
109
- }
110
- });
111
- it('rejects a non-string cwd with a clean error message (not a leaked TypeError)', async () => {
112
- const r = await shellExecHandler({ command: 'node', args: [], cwd: 42 }, ctx());
113
- expect(r.is_error).toBe(true);
114
- expect(r.content).toMatch(/cwd must be a string/i);
115
- expect(r.content).not.toMatch(/typeerror|includes is not a function/i);
116
- });
117
- it('rejects a non-number timeout_ms with a clean error message', async () => {
118
- const r = await shellExecHandler({ command: 'node', args: [], timeout_ms: 'fast' }, ctx());
119
- expect(r.is_error).toBe(true);
120
- expect(r.content).toMatch(/timeout_ms must be a number/i);
121
- });
122
- it('rejects an argv array longer than the cap (defense-in-depth against memory exhaustion)', async () => {
123
- const huge = Array.from({ length: 1_001 }, () => 'x');
124
- const r = await shellExecHandler({ command: 'node', args: huge }, ctx());
125
- expect(r.is_error).toBe(true);
126
- expect(r.content).toMatch(/too many args/i);
127
- });
128
- it('rejects an individual arg larger than the per-arg cap', async () => {
129
- const tooBig = 'x'.repeat(100_001);
130
- const r = await shellExecHandler({ command: 'node', args: [tooBig] }, ctx());
131
- expect(r.is_error).toBe(true);
132
- expect(r.content).toMatch(/exceeds max length/i);
133
- expect(r.content).toMatch(/index 0/);
134
- });
135
- });
@@ -1,106 +0,0 @@
1
- /**
2
- * B.2 tests: sap_transport_release safety confirmation gate (Rule 9).
3
- *
4
- * Verifies that:
5
- * - When safety mode is ON and user confirms, presentSafetyConfirmation is called
6
- * with the correct SafetyAction shape (rule=RULE_9_TRANSPORT_RELEASE) and
7
- * transportRelease proceeds.
8
- * - When safety mode is ON and user declines, cancelled_by_user is returned and
9
- * transportRelease is NOT called.
10
- */
11
- import '../transport.js'; // triggers registration side-effect
12
- import { getTool } from '../index.js';
13
- import { describe, it, expect, vi, beforeEach } from 'vitest';
14
- // ---------------------------------------------------------------------------
15
- // Mock @cspeach/sap-client (auditLog used by transport.ts)
16
- // ---------------------------------------------------------------------------
17
- vi.mock('@cspeach/sap-client', async (importOriginal) => {
18
- const actual = await importOriginal();
19
- return {
20
- ...actual,
21
- auditLog: vi.fn().mockResolvedValue(undefined),
22
- };
23
- });
24
- // ---------------------------------------------------------------------------
25
- // Mock verifyAndSpendApprovalId to bypass JWT check
26
- // ---------------------------------------------------------------------------
27
- const mockVerifyAndSpend = vi.hoisted(() => vi.fn());
28
- vi.mock('../../approvals/jwt.js', () => ({
29
- verifyAndSpendApprovalId: mockVerifyAndSpend,
30
- mintApprovalId: vi.fn(),
31
- }));
32
- // ---------------------------------------------------------------------------
33
- // Mock safety-mode-state and safety-confirm
34
- // ---------------------------------------------------------------------------
35
- const mockIsSafetyConfirmEnabled = vi.hoisted(() => vi.fn());
36
- vi.mock('../../repl/safety-mode-state.js', () => ({
37
- isSafetyConfirmEnabled: mockIsSafetyConfirmEnabled,
38
- }));
39
- const mockPresentSafetyConfirmation = vi.hoisted(() => vi.fn());
40
- vi.mock('../../repl/safety-confirm.js', () => ({
41
- presentSafetyConfirmation: mockPresentSafetyConfirmation,
42
- }));
43
- // ---------------------------------------------------------------------------
44
- // Helper: build a minimal ToolContext stub
45
- // ---------------------------------------------------------------------------
46
- function makeCtx(adt) {
47
- return {
48
- adt: adt,
49
- sapAlias: 'dev',
50
- session: { toolCalls: [] },
51
- cwd: '.',
52
- };
53
- }
54
- beforeEach(() => {
55
- vi.clearAllMocks();
56
- // Default: approval always succeeds
57
- mockVerifyAndSpend.mockResolvedValue({ ok: true, payload: {} });
58
- // Default: safety mode OFF
59
- mockIsSafetyConfirmEnabled.mockReturnValue(false);
60
- });
61
- // ---------------------------------------------------------------------------
62
- // B.2 sap_transport_release — safety confirmation gate (Rule 9)
63
- // ---------------------------------------------------------------------------
64
- describe('sap_transport_release safety confirmation', () => {
65
- it('calls presentSafetyConfirmation with correct SafetyAction and proceeds when confirmed', async () => {
66
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
67
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: true });
68
- const mockTransportRelease = vi.fn().mockResolvedValue({
69
- operation: 'release',
70
- transportNumber: 'DEVK900123',
71
- status: 'released',
72
- });
73
- const adt = { transportRelease: mockTransportRelease };
74
- const tool = getTool('sap_transport_release');
75
- const result = await tool.handler({ transport: 'DEVK900123', approval_id: 'dummy-jwt' }, makeCtx(adt));
76
- // Safety confirmation must have been called with correct shape
77
- expect(mockPresentSafetyConfirmation).toHaveBeenCalledOnce();
78
- const action = mockPresentSafetyConfirmation.mock.calls[0][0];
79
- expect(action.rule).toBe('RULE_9_TRANSPORT_RELEASE');
80
- expect(action.op).toBe('transport_release');
81
- expect(action.object).toEqual({ type: 'TRANSPORT', name: 'DEVK900123' });
82
- expect(action.what_will_happen).toBeTruthy();
83
- expect(action.rollback_available).toBe(false);
84
- expect(action.transport).toBe('DEVK900123');
85
- // transportRelease must have been called (confirmed)
86
- expect(mockTransportRelease).toHaveBeenCalledOnce();
87
- expect(mockTransportRelease).toHaveBeenCalledWith('DEVK900123');
88
- const parsed = JSON.parse(result.content);
89
- expect(parsed.released).toBe(true);
90
- expect(result.is_error).toBeFalsy();
91
- });
92
- it('returns cancelled_by_user and does NOT call transportRelease when user declines', async () => {
93
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
94
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: false, reason: 'not ready' });
95
- const mockTransportRelease = vi.fn();
96
- const adt = { transportRelease: mockTransportRelease };
97
- const tool = getTool('sap_transport_release');
98
- const result = await tool.handler({ transport: 'DEVK900123', approval_id: 'dummy-jwt' }, makeCtx(adt));
99
- expect(result.is_error).toBe(true);
100
- const parsed = JSON.parse(result.content);
101
- expect(parsed.error).toBe('cancelled_by_user');
102
- expect(parsed.reason).toBe('not ready');
103
- // Critical: transportRelease must NOT be called
104
- expect(mockTransportRelease).not.toHaveBeenCalled();
105
- });
106
- });
@@ -1,128 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { interceptUpdateMethod, extractMethodBody, CancelledByUserError, } from '../update-method-intercept.js';
3
- const SAMPLE_CLASS = `CLASS zcl_demo DEFINITION PUBLIC FINAL CREATE PUBLIC.
4
- PUBLIC SECTION.
5
- METHODS dispatch.
6
- METHODS run_diff.
7
- ENDCLASS.
8
-
9
- CLASS zcl_demo IMPLEMENTATION.
10
-
11
- METHOD dispatch.
12
- DATA(lv_x) = 1.
13
- lv_x = lv_x + 2.
14
- " inline comment with " a quote
15
- WRITE: lv_x.
16
- ENDMETHOD.
17
-
18
- METHOD run_diff.
19
- RETURN.
20
- ENDMETHOD.
21
-
22
- ENDCLASS.`;
23
- describe('extractMethodBody', () => {
24
- it('returns the named method block including METHOD and ENDMETHOD', () => {
25
- const body = extractMethodBody(SAMPLE_CLASS, 'dispatch');
26
- expect(body).toMatch(/^\s*METHOD dispatch\.$/m);
27
- expect(body).toMatch(/^\s*ENDMETHOD\.$/m);
28
- expect(body).toContain('DATA(lv_x) = 1.');
29
- // Should not bleed into run_diff's body.
30
- expect(body).not.toContain('METHOD run_diff');
31
- });
32
- it('is case-insensitive for the method name', () => {
33
- expect(extractMethodBody(SAMPLE_CLASS, 'DISPATCH')).toContain('DATA(lv_x)');
34
- expect(extractMethodBody(SAMPLE_CLASS, 'Dispatch')).toContain('DATA(lv_x)');
35
- });
36
- it('returns empty string when method is not found', () => {
37
- expect(extractMethodBody(SAMPLE_CLASS, 'nonexistent')).toBe('');
38
- });
39
- it('handles a method whose body contains an inline " comment', () => {
40
- // The sample dispatch body has `" inline comment with " a quote` — the
41
- // parser should NOT treat the `"` inside the comment as ending the body.
42
- const body = extractMethodBody(SAMPLE_CLASS, 'dispatch');
43
- expect(body).toContain('inline comment');
44
- });
45
- it('handles a method whose body contains string literals with keywords', () => {
46
- const src = `CLASS x IMPLEMENTATION.
47
- METHOD foo.
48
- DATA(lv) = 'METHOD bar. ENDMETHOD.'.
49
- DATA(lw) = \`ENDMETHOD pretend\`.
50
- RETURN.
51
- ENDMETHOD.
52
-
53
- METHOD other.
54
- WRITE 'hi'.
55
- ENDMETHOD.
56
- ENDCLASS.`;
57
- const body = extractMethodBody(src, 'foo');
58
- expect(body).toMatch(/RETURN\./);
59
- // The parser must stop at the REAL ENDMETHOD (not the ones inside the
60
- // string literals on lines 3 and 4), so the body should NOT bleed into
61
- // METHOD other.
62
- expect(body).not.toContain('METHOD other');
63
- expect(body).not.toContain("WRITE 'hi'");
64
- });
65
- });
66
- describe('interceptUpdateMethod', () => {
67
- function makeClient(source) {
68
- return {
69
- getSource: vi.fn(async () => source),
70
- };
71
- }
72
- it('returns the call when the hook confirms', async () => {
73
- const client = makeClient(SAMPLE_CLASS);
74
- const hook = vi.fn(async () => ({ confirmed: true }));
75
- const call = {
76
- className: 'ZCL_DEMO',
77
- methodName: 'dispatch',
78
- methodSource: 'METHOD dispatch.\n RETURN.\nENDMETHOD.',
79
- transport: 'SXXK999999',
80
- };
81
- const result = await interceptUpdateMethod(client, call, hook);
82
- expect(result).toBe(call);
83
- expect(hook).toHaveBeenCalledOnce();
84
- const passed = hook.mock.calls[0][0];
85
- expect(passed.currentBody).toContain('DATA(lv_x) = 1.');
86
- expect(passed.proposedBody).toBe(call.methodSource);
87
- });
88
- it('throws CancelledByUserError when the hook rejects', async () => {
89
- const client = makeClient(SAMPLE_CLASS);
90
- const hook = async () => ({ confirmed: false });
91
- const call = {
92
- className: 'ZCL_DEMO',
93
- methodName: 'dispatch',
94
- methodSource: 'METHOD dispatch.\nENDMETHOD.',
95
- transport: 'SXXK999999',
96
- };
97
- await expect(interceptUpdateMethod(client, call, hook)).rejects.toThrow(CancelledByUserError);
98
- });
99
- it('passes empty currentBody when method does not exist on current class', async () => {
100
- const client = makeClient(SAMPLE_CLASS);
101
- const hook = vi.fn(async () => ({ confirmed: true }));
102
- const call = {
103
- className: 'ZCL_DEMO',
104
- methodName: 'brand_new_method',
105
- methodSource: 'METHOD brand_new_method.\n RETURN.\nENDMETHOD.',
106
- transport: 'SXXK999999',
107
- };
108
- await interceptUpdateMethod(client, call, hook);
109
- const passed = hook.mock.calls[0][0];
110
- expect(passed.currentBody).toBe('');
111
- expect(passed.proposedBody).toBe(call.methodSource);
112
- });
113
- it('propagates a getSource failure unchanged', async () => {
114
- const client = {
115
- getSource: async () => {
116
- throw new Error('ADT 401');
117
- },
118
- };
119
- const hook = async () => ({ confirmed: true });
120
- const call = {
121
- className: 'ZCL_DEMO',
122
- methodName: 'dispatch',
123
- methodSource: 'METHOD dispatch.\nENDMETHOD.',
124
- transport: 'SXXK999999',
125
- };
126
- await expect(interceptUpdateMethod(client, call, hook)).rejects.toThrow('ADT 401');
127
- });
128
- });