@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,282 +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 { extractFiori } from '../domain/fiori.js';
6
- let root;
7
- beforeEach(async () => {
8
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-fiori-'));
9
- });
10
- afterEach(async () => {
11
- await fs.rm(root, { recursive: true, force: true });
12
- });
13
- async function writeFile(rel, content = '') {
14
- const full = path.join(root, rel);
15
- await fs.mkdir(path.dirname(full), { recursive: true });
16
- await fs.writeFile(full, content, 'utf-8');
17
- }
18
- describe('extractFiori — empty / missing', () => {
19
- it('returns empty everything when webapp/ does not exist', async () => {
20
- const result = await extractFiori(root);
21
- expect(result).toEqual({
22
- type: 'fiori-ui5',
23
- controllers: [],
24
- views: [],
25
- routes: [],
26
- i18nKeys: [],
27
- });
28
- });
29
- it('returns empty arrays when webapp/ exists but is empty', async () => {
30
- await fs.mkdir(path.join(root, 'webapp'));
31
- const result = await extractFiori(root);
32
- if (result.type !== 'fiori-ui5')
33
- throw new Error('wrong variant');
34
- expect(result.routes).toEqual([]);
35
- expect(result.controllers).toEqual([]);
36
- expect(result.views).toEqual([]);
37
- expect(result.i18nKeys).toEqual([]);
38
- });
39
- });
40
- describe('extractFiori — manifest.json routes', () => {
41
- it('parses routes declared as an array', async () => {
42
- await writeFile('webapp/manifest.json', JSON.stringify({
43
- 'sap.ui5': {
44
- routing: {
45
- routes: [
46
- { name: 'worklist', pattern: '', target: 'worklist' },
47
- { name: 'detail', pattern: 'detail/{id}', target: ['detail'] },
48
- ],
49
- },
50
- },
51
- }));
52
- const result = await extractFiori(root);
53
- if (result.type !== 'fiori-ui5')
54
- throw new Error('wrong variant');
55
- expect(result.routes).toEqual([
56
- { name: 'worklist', pattern: '', targets: ['worklist'] },
57
- { name: 'detail', pattern: 'detail/{id}', targets: ['detail'] },
58
- ]);
59
- });
60
- it('parses routes declared as an object map (legacy form)', async () => {
61
- await writeFile('webapp/manifest.json', JSON.stringify({
62
- 'sap.ui5': {
63
- routing: {
64
- routes: {
65
- home: { pattern: '', target: 'main' },
66
- },
67
- },
68
- },
69
- }));
70
- const result = await extractFiori(root);
71
- if (result.type !== 'fiori-ui5')
72
- throw new Error('wrong variant');
73
- expect(result.routes).toEqual([
74
- { name: 'home', pattern: '', targets: ['main'] },
75
- ]);
76
- });
77
- it('returns empty routes on malformed manifest.json (does not throw)', async () => {
78
- await writeFile('webapp/manifest.json', '{ this is not JSON');
79
- const result = await extractFiori(root);
80
- if (result.type !== 'fiori-ui5')
81
- throw new Error('wrong variant');
82
- expect(result.routes).toEqual([]);
83
- });
84
- it('returns empty routes when manifest has no routing section', async () => {
85
- await writeFile('webapp/manifest.json', JSON.stringify({ 'sap.app': { id: 'myapp' } }));
86
- const result = await extractFiori(root);
87
- if (result.type !== 'fiori-ui5')
88
- throw new Error('wrong variant');
89
- expect(result.routes).toEqual([]);
90
- });
91
- });
92
- describe('extractFiori — controllers', () => {
93
- it('collects controllers and extracts i18n key references', async () => {
94
- await writeFile('webapp/controller/Worklist.controller.js', `sap.ui.define([], function () {
95
- return Controller.extend("myapp.controller.Worklist", {
96
- onInit: function () {
97
- var msg = this.getResourceBundle().getText("worklistTitle");
98
- var sub = this.getView().getModel("i18n").getResourceBundle().getText('worklistDescription');
99
- }
100
- });
101
- });`);
102
- const result = await extractFiori(root);
103
- if (result.type !== 'fiori-ui5')
104
- throw new Error('wrong variant');
105
- expect(result.controllers).toHaveLength(1);
106
- expect(result.controllers[0]).toMatchObject({
107
- name: 'Worklist',
108
- path: 'webapp/controller/Worklist.controller.js',
109
- });
110
- expect(result.controllers[0].i18nKeys.sort()).toEqual([
111
- 'worklistDescription',
112
- 'worklistTitle',
113
- ]);
114
- });
115
- it('handles controllers with no i18n usage', async () => {
116
- await writeFile('webapp/controller/App.controller.js', `sap.ui.define([], function () { return Controller.extend("myapp.controller.App", {}); });`);
117
- const result = await extractFiori(root);
118
- if (result.type !== 'fiori-ui5')
119
- throw new Error('wrong variant');
120
- expect(result.controllers[0].i18nKeys).toEqual([]);
121
- });
122
- it('skips files that are not *.controller.js', async () => {
123
- await writeFile('webapp/controller/helper.js', 'export function foo() {}');
124
- await writeFile('webapp/controller/Real.controller.js', `Controller.extend("myapp.controller.Real", {});`);
125
- const result = await extractFiori(root);
126
- if (result.type !== 'fiori-ui5')
127
- throw new Error('wrong variant');
128
- expect(result.controllers.map((c) => c.name)).toEqual(['Real']);
129
- });
130
- });
131
- describe('extractFiori — views and cross-linking', () => {
132
- it('links view → controller via controllerName attribute', async () => {
133
- await writeFile('webapp/controller/Worklist.controller.js', `Controller.extend("myapp.controller.Worklist", {});`);
134
- await writeFile('webapp/view/Worklist.view.xml', `<mvc:View controllerName="myapp.controller.Worklist" xmlns:mvc="sap.ui.core.mvc" />`);
135
- const result = await extractFiori(root);
136
- if (result.type !== 'fiori-ui5')
137
- throw new Error('wrong variant');
138
- expect(result.views[0].controllerPath).toBe('webapp/controller/Worklist.controller.js');
139
- expect(result.controllers[0].viewPath).toBe('webapp/view/Worklist.view.xml');
140
- });
141
- it('falls back to basename match when controllerName attr absent', async () => {
142
- await writeFile('webapp/controller/Detail.controller.js', `Controller.extend("Detail", {});`);
143
- await writeFile('webapp/view/Detail.view.xml', `<mvc:View />`);
144
- const result = await extractFiori(root);
145
- if (result.type !== 'fiori-ui5')
146
- throw new Error('wrong variant');
147
- expect(result.views[0].controllerPath).toBe('webapp/controller/Detail.controller.js');
148
- });
149
- it('extracts model paths from `{path: ...}` and `{/...}` bindings', async () => {
150
- await writeFile('webapp/view/Worklist.view.xml', `<List items="{path: '/Worklist/items'}">
151
- <Text text="{/header/title}" />
152
- <Text text="{i18n>welcome}" />
153
- </List>`);
154
- const result = await extractFiori(root);
155
- if (result.type !== 'fiori-ui5')
156
- throw new Error('wrong variant');
157
- expect(result.views[0].boundModelPaths.sort()).toEqual([
158
- '/Worklist/items',
159
- '/header/title',
160
- ]);
161
- });
162
- it('captures composite bindings (text="prefix {/name} suffix")', async () => {
163
- // Common UI5 pattern: a label that mixes literal text and a binding.
164
- // Earlier regex required quote/brace boundaries either side of the
165
- // binding; this ruled composite bindings out. Now they're captured.
166
- await writeFile('webapp/view/Greeting.view.xml', `<mvc:View>
167
- <Text text="Hello {/user/name}, welcome!" />
168
- <Text text="{/header/title} — {/header/subtitle}" />
169
- </mvc:View>`);
170
- const result = await extractFiori(root);
171
- if (result.type !== 'fiori-ui5')
172
- throw new Error('wrong variant');
173
- expect(result.views[0].boundModelPaths.sort()).toEqual([
174
- '/header/subtitle',
175
- '/header/title',
176
- '/user/name',
177
- ]);
178
- });
179
- it('captures multiple bindings in a single attribute', async () => {
180
- await writeFile('webapp/view/MultiBind.view.xml', `<mvc:View>
181
- <Text text="{/a} and {/b} and {/c}" />
182
- </mvc:View>`);
183
- const result = await extractFiori(root);
184
- if (result.type !== 'fiori-ui5')
185
- throw new Error('wrong variant');
186
- expect(result.views[0].boundModelPaths).toEqual(['/a', '/b', '/c']);
187
- });
188
- it('skips named-model bindings like {i18n>title} and {modelAlias>path}', async () => {
189
- await writeFile('webapp/view/Aliases.view.xml', `<mvc:View>
190
- <Text text="{i18n>welcomeTitle}" />
191
- <Text text="{customModel>/some/path}" />
192
- <Text text="{/real/path}" />
193
- </mvc:View>`);
194
- const result = await extractFiori(root);
195
- if (result.type !== 'fiori-ui5')
196
- throw new Error('wrong variant');
197
- // Only the unaliased absolute path survives.
198
- expect(result.views[0].boundModelPaths).toEqual(['/real/path']);
199
- });
200
- it('view with no matching controller has controllerPath null', async () => {
201
- await writeFile('webapp/view/Orphan.view.xml', `<mvc:View />`);
202
- const result = await extractFiori(root);
203
- if (result.type !== 'fiori-ui5')
204
- throw new Error('wrong variant');
205
- expect(result.views[0].controllerPath).toBeNull();
206
- });
207
- });
208
- describe('extractFiori — i18n properties', () => {
209
- it('collects keys from i18n.properties', async () => {
210
- await writeFile('webapp/i18n/i18n.properties', `# This is a comment
211
- ! Also a comment
212
- worklistTitle=My Worklist
213
- worklistDescription=A description
214
- blank=
215
- `);
216
- const result = await extractFiori(root);
217
- if (result.type !== 'fiori-ui5')
218
- throw new Error('wrong variant');
219
- expect(result.i18nKeys.sort()).toEqual([
220
- 'blank',
221
- 'worklistDescription',
222
- 'worklistTitle',
223
- ]);
224
- });
225
- it('dedupes keys across multiple .properties files', async () => {
226
- await writeFile('webapp/i18n/i18n.properties', 'shared=A\nonlyEN=hi\n');
227
- await writeFile('webapp/i18n/i18n_de.properties', 'shared=A\nonlyDE=hallo\n');
228
- const result = await extractFiori(root);
229
- if (result.type !== 'fiori-ui5')
230
- throw new Error('wrong variant');
231
- expect(result.i18nKeys.sort()).toEqual(['onlyDE', 'onlyEN', 'shared']);
232
- });
233
- it('handles colon-separated and equals-separated entries', async () => {
234
- await writeFile('webapp/i18n/i18n.properties', `keyEq=value\nkeyColon : value\n`);
235
- const result = await extractFiori(root);
236
- if (result.type !== 'fiori-ui5')
237
- throw new Error('wrong variant');
238
- expect(result.i18nKeys.sort()).toEqual(['keyColon', 'keyEq']);
239
- });
240
- });
241
- describe('extractFiori — realistic Worklist app fixture', () => {
242
- it('cross-links a small app end-to-end', async () => {
243
- await writeFile('webapp/manifest.json', JSON.stringify({
244
- 'sap.app': { id: 'sap.ui.demo.worklist' },
245
- 'sap.ui5': {
246
- routing: {
247
- routes: [
248
- { name: 'worklist', pattern: '', target: 'worklist' },
249
- { name: 'object', pattern: 'Objects/{objectId}', target: 'object' },
250
- ],
251
- },
252
- },
253
- }));
254
- await writeFile('webapp/controller/Worklist.controller.js', `Controller.extend("sap.ui.demo.worklist.controller.Worklist", {
255
- onInit: function () {
256
- this.getResourceBundle().getText("worklistTitleCount");
257
- }
258
- });`);
259
- await writeFile('webapp/controller/Object.controller.js', `Controller.extend("sap.ui.demo.worklist.controller.Object", {
260
- onInit: function () {
261
- this.getResourceBundle().getText("objectTitle");
262
- }
263
- });`);
264
- await writeFile('webapp/view/Worklist.view.xml', `<mvc:View controllerName="sap.ui.demo.worklist.controller.Worklist" xmlns:mvc="sap.ui.core.mvc">
265
- <Table items="{path: '/Objects'}" />
266
- </mvc:View>`);
267
- await writeFile('webapp/view/Object.view.xml', `<mvc:View controllerName="sap.ui.demo.worklist.controller.Object" />`);
268
- await writeFile('webapp/i18n/i18n.properties', `worklistTitleCount=Objects ({0})\nobjectTitle=Object\n`);
269
- const result = await extractFiori(root);
270
- if (result.type !== 'fiori-ui5')
271
- throw new Error('wrong variant');
272
- expect(result.routes.map((r) => r.name).sort()).toEqual(['object', 'worklist']);
273
- expect(result.controllers.map((c) => c.name).sort()).toEqual(['Object', 'Worklist']);
274
- expect(result.views.map((v) => v.name).sort()).toEqual(['Object', 'Worklist']);
275
- const wlController = result.controllers.find((c) => c.name === 'Worklist');
276
- const wlView = result.views.find((v) => v.name === 'Worklist');
277
- expect(wlController.viewPath).toBe('webapp/view/Worklist.view.xml');
278
- expect(wlView.controllerPath).toBe('webapp/controller/Worklist.controller.js');
279
- expect(wlView.boundModelPaths).toEqual(['/Objects']);
280
- expect(result.i18nKeys.sort()).toEqual(['objectTitle', 'worklistTitleCount']);
281
- });
282
- });
@@ -1,131 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { spawn } from 'node:child_process';
3
- import { promises as fs } from 'node:fs';
4
- import * as os from 'node:os';
5
- import * as path from 'node:path';
6
- import { getGitState } from '../git.js';
7
- let root;
8
- beforeEach(async () => {
9
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-git-'));
10
- });
11
- afterEach(async () => {
12
- await fs.rm(root, { recursive: true, force: true });
13
- });
14
- function runGit(cwd, args) {
15
- return new Promise((resolve) => {
16
- let stdout = '';
17
- const child = spawn('git', args, {
18
- cwd,
19
- windowsHide: true,
20
- stdio: ['ignore', 'pipe', 'inherit'],
21
- });
22
- child.stdout.on('data', (c) => {
23
- stdout += c.toString('utf-8');
24
- });
25
- child.on('close', (code) => {
26
- resolve({ code: code ?? 1, stdout });
27
- });
28
- child.on('error', () => resolve({ code: 1, stdout }));
29
- });
30
- }
31
- async function gitInit(cwd) {
32
- // Use a deterministic default branch name + identity so tests don't
33
- // depend on the developer's global git config.
34
- await runGit(cwd, ['init', '-b', 'main']);
35
- await runGit(cwd, ['config', 'user.email', 'test@cspeach.dev']);
36
- await runGit(cwd, ['config', 'user.name', 'Test User']);
37
- await runGit(cwd, ['config', 'commit.gpgsign', 'false']);
38
- }
39
- async function gitCommit(cwd, file, message) {
40
- await fs.writeFile(path.join(cwd, file), `content for ${file}\n`, 'utf-8');
41
- await runGit(cwd, ['add', file]);
42
- await runGit(cwd, ['commit', '-m', message]);
43
- }
44
- describe('getGitState — non-repo', () => {
45
- it('returns empty state when cwd is not a git repo', async () => {
46
- const state = await getGitState(root);
47
- expect(state).toEqual({
48
- isRepo: false,
49
- branch: null,
50
- headSha: null,
51
- dirtyFileCount: 0,
52
- recentCommits: [],
53
- });
54
- });
55
- it('returns empty state when cwd does not exist', async () => {
56
- const state = await getGitState(path.join(root, 'nope'));
57
- expect(state.isRepo).toBe(false);
58
- });
59
- });
60
- describe('getGitState — clean repo', () => {
61
- it('returns branch, head sha, zero dirt, recent commits', async () => {
62
- await gitInit(root);
63
- await gitCommit(root, 'a.txt', 'first commit');
64
- await gitCommit(root, 'b.txt', 'second commit');
65
- const state = await getGitState(root);
66
- expect(state.isRepo).toBe(true);
67
- expect(state.branch).toBe('main');
68
- expect(state.headSha).toMatch(/^[0-9a-f]{40}$/);
69
- expect(state.dirtyFileCount).toBe(0);
70
- expect(state.recentCommits).toHaveLength(2);
71
- expect(state.recentCommits[0]).toMatch(/^[0-9a-f]{7,} second commit$/);
72
- expect(state.recentCommits[1]).toMatch(/^[0-9a-f]{7,} first commit$/);
73
- });
74
- it('caps recent commits at 5', async () => {
75
- // Bumped timeout: 8 sequential git commits spawn 16+ subprocesses
76
- // (init + config + add + commit per loop). Under parallel-suite
77
- // load on Windows, this can exceed the default 5s timeout. Same
78
- // pattern as turn-stream and index-files truncate tests.
79
- await gitInit(root);
80
- for (let i = 1; i <= 8; i++) {
81
- await gitCommit(root, `f${i}.txt`, `commit ${i}`);
82
- }
83
- const state = await getGitState(root);
84
- expect(state.recentCommits).toHaveLength(5);
85
- // Most recent first.
86
- expect(state.recentCommits[0]).toMatch(/commit 8$/);
87
- expect(state.recentCommits[4]).toMatch(/commit 4$/);
88
- }, 30_000);
89
- });
90
- describe('getGitState — dirty repo', () => {
91
- it('counts unstaged + staged + untracked files', async () => {
92
- await gitInit(root);
93
- await gitCommit(root, 'tracked.txt', 'initial');
94
- // Modify tracked
95
- await fs.writeFile(path.join(root, 'tracked.txt'), 'modified\n', 'utf-8');
96
- // Add untracked
97
- await fs.writeFile(path.join(root, 'new.txt'), 'new\n', 'utf-8');
98
- // Add a staged file too
99
- await fs.writeFile(path.join(root, 'staged.txt'), 'staged\n', 'utf-8');
100
- await runGit(root, ['add', 'staged.txt']);
101
- const state = await getGitState(root);
102
- expect(state.isRepo).toBe(true);
103
- expect(state.dirtyFileCount).toBe(3);
104
- });
105
- });
106
- describe('getGitState — detached HEAD', () => {
107
- it('returns "HEAD" as branch when in detached state', async () => {
108
- await gitInit(root);
109
- await gitCommit(root, 'a.txt', 'first');
110
- await gitCommit(root, 'b.txt', 'second');
111
- // Detach to first commit's SHA
112
- const log = await runGit(root, ['rev-list', '--reverse', 'HEAD']);
113
- const firstSha = log.stdout.trim().split('\n')[0];
114
- await runGit(root, ['checkout', '--detach', firstSha]);
115
- const state = await getGitState(root);
116
- expect(state.isRepo).toBe(true);
117
- expect(state.branch).toBe('HEAD');
118
- });
119
- });
120
- describe('getGitState — empty repo (no commits)', () => {
121
- it('reports isRepo=true with null sha and empty commits', async () => {
122
- await gitInit(root);
123
- const state = await getGitState(root);
124
- expect(state.isRepo).toBe(true);
125
- // branch may be 'main' (configured default) or empty depending on git
126
- // version; we don't assert on it strictly. headSha is null because
127
- // HEAD doesn't resolve before the first commit.
128
- expect(state.headSha).toBeNull();
129
- expect(state.recentCommits).toEqual([]);
130
- });
131
- });
@@ -1,195 +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 { indexFiles } from '../index-files.js';
6
- let root;
7
- beforeEach(async () => {
8
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-idx-'));
9
- });
10
- afterEach(async () => {
11
- // maxRetries handles Windows fs.rm ENOTEMPTY when antivirus / indexer
12
- // holds a transient handle on freshly-written files. The large-N
13
- // writeFile loops in this file (600+ files) hit it intermittently
14
- // under parallel-suite load.
15
- await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
16
- });
17
- async function writeFile(rel, content = '') {
18
- const full = path.join(root, rel);
19
- await fs.mkdir(path.dirname(full), { recursive: true });
20
- await fs.writeFile(full, content, 'utf-8');
21
- }
22
- async function makeDir(rel) {
23
- await fs.mkdir(path.join(root, rel), { recursive: true });
24
- }
25
- describe('indexFiles — base cases', () => {
26
- it('empty directory → totalFiles=0, empty paths', async () => {
27
- const idx = await indexFiles(root);
28
- expect(idx.totalFiles).toBe(0);
29
- expect(idx.paths).toEqual([]);
30
- expect(idx.byExtension).toEqual({});
31
- expect(idx.truncated).toBe(false);
32
- });
33
- it('non-existent directory → empty index (never throws)', async () => {
34
- const idx = await indexFiles(path.join(root, 'nope'));
35
- expect(idx.totalFiles).toBe(0);
36
- expect(idx.truncated).toBe(false);
37
- });
38
- it('counts files by extension', async () => {
39
- await writeFile('a.ts', 'export {};');
40
- await writeFile('b.ts', 'export {};');
41
- await writeFile('c.abap', 'REPORT zfoo.');
42
- await writeFile('readme.md', '# hi');
43
- const idx = await indexFiles(root);
44
- expect(idx.totalFiles).toBe(4);
45
- expect(idx.byExtension['.ts']).toBe(2);
46
- expect(idx.byExtension['.abap']).toBe(1);
47
- expect(idx.byExtension['.md']).toBe(1);
48
- });
49
- it('returns top-level dirs alphabetically (skipping always-skip dirs)', async () => {
50
- await makeDir('src');
51
- await makeDir('docs');
52
- await makeDir('node_modules');
53
- await makeDir('.git');
54
- const idx = await indexFiles(root);
55
- expect(idx.topLevelDirs).toEqual(['docs', 'src']);
56
- });
57
- });
58
- describe('indexFiles — always-skipped noise dirs', () => {
59
- it('skips node_modules entirely', async () => {
60
- await writeFile('node_modules/junk.ts');
61
- await writeFile('src/real.ts');
62
- const idx = await indexFiles(root);
63
- expect(idx.totalFiles).toBe(1);
64
- expect(idx.paths).toEqual(['src/real.ts']);
65
- });
66
- it('skips .git, dist, build, .cspeach, .abapforge', async () => {
67
- await writeFile('.git/HEAD');
68
- await writeFile('dist/bundle.js');
69
- await writeFile('build/output.js');
70
- await writeFile('.cspeach/foo.json');
71
- await writeFile('.abapforge/bar.json');
72
- await writeFile('src/real.ts');
73
- const idx = await indexFiles(root);
74
- expect(idx.totalFiles).toBe(1);
75
- expect(idx.paths).toEqual(['src/real.ts']);
76
- });
77
- });
78
- describe('indexFiles — .gitignore handling', () => {
79
- it('honors basename patterns from .gitignore', async () => {
80
- await writeFile('.gitignore', 'secrets.env\n*.log\n');
81
- await writeFile('secrets.env', 'PASSWORD=foo');
82
- await writeFile('app.log', 'log content');
83
- await writeFile('keep.ts', 'export {};');
84
- const idx = await indexFiles(root);
85
- // The two ignored files should NOT appear; .gitignore itself does
86
- // (it's a useful project file the agent should be aware of).
87
- expect(idx.paths).toContain('keep.ts');
88
- expect(idx.paths).not.toContain('secrets.env');
89
- expect(idx.paths).not.toContain('app.log');
90
- });
91
- it('honors directory patterns ending with /', async () => {
92
- await writeFile('.gitignore', 'coverage/\n');
93
- await writeFile('coverage/lcov.info');
94
- await writeFile('coverage/sub/data.json');
95
- await writeFile('keep.ts');
96
- const idx = await indexFiles(root);
97
- expect(idx.paths.includes('coverage/lcov.info')).toBe(false);
98
- expect(idx.paths.includes('keep.ts')).toBe(true);
99
- });
100
- it('ignores comments and blank lines', async () => {
101
- await writeFile('.gitignore', '# comment\n\n*.tmp\n# another\n');
102
- await writeFile('throwaway.tmp');
103
- await writeFile('keep.ts');
104
- const idx = await indexFiles(root);
105
- expect(idx.paths.includes('throwaway.tmp')).toBe(false);
106
- expect(idx.paths.includes('keep.ts')).toBe(true);
107
- });
108
- it('does not crash on patterns we don\'t support (negation, root-rooted paths)', async () => {
109
- // We silently ignore lines we can't parse — the file shouldn't crash the walker.
110
- await writeFile('.gitignore', '!important.txt\n/specific/path\nname\n');
111
- await writeFile('important.txt');
112
- await writeFile('name');
113
- await writeFile('keep.ts');
114
- const idx = await indexFiles(root);
115
- // 'name' is a basename match → skipped.
116
- // 'important.txt' negation is not supported → still indexed.
117
- // '/specific/path' is silently ignored.
118
- expect(idx.paths.includes('keep.ts')).toBe(true);
119
- expect(idx.paths.includes('name')).toBe(false);
120
- expect(idx.paths.includes('important.txt')).toBe(true);
121
- });
122
- it('honors .cspeachignore as well (separate file)', async () => {
123
- await writeFile('.cspeachignore', 'private-notes\n');
124
- await writeFile('private-notes');
125
- await writeFile('keep.ts');
126
- const idx = await indexFiles(root);
127
- expect(idx.paths.includes('private-notes')).toBe(false);
128
- expect(idx.paths.includes('keep.ts')).toBe(true);
129
- });
130
- });
131
- describe('indexFiles — ranking + caps', () => {
132
- it('source files rank ahead of non-source files', async () => {
133
- // Write same-mtime mix; source should sort first.
134
- await writeFile('a.txt', 'plain');
135
- await writeFile('b.ts', 'source');
136
- await writeFile('c.abap', 'source');
137
- const idx = await indexFiles(root);
138
- // Source files come first in paths[].
139
- const tsIdx = idx.paths.indexOf('b.ts');
140
- const abapIdx = idx.paths.indexOf('c.abap');
141
- const txtIdx = idx.paths.indexOf('a.txt');
142
- expect(tsIdx).toBeGreaterThanOrEqual(0);
143
- expect(abapIdx).toBeGreaterThanOrEqual(0);
144
- expect(txtIdx).toBeGreaterThanOrEqual(0);
145
- expect(Math.max(tsIdx, abapIdx)).toBeLessThan(txtIdx);
146
- });
147
- it('truncates paths[] to 500 entries on huge projects', async () => {
148
- // Write 600 source files; expect paths capped at 500 + totalFiles=600.
149
- // Bumped to 60s: on Windows with antivirus active, 600 sequential
150
- // writeFile calls measure at ~50-60ms each (defender scans every new
151
- // file). Logic is correct; this is FS/AV-bound, not the indexer.
152
- for (let i = 0; i < 600; i++) {
153
- await writeFile(`src/file-${i.toString().padStart(3, '0')}.ts`);
154
- }
155
- const idx = await indexFiles(root);
156
- expect(idx.totalFiles).toBe(600);
157
- expect(idx.paths.length).toBe(500);
158
- expect(idx.truncated).toBe(false); // still under 10K traversed
159
- }, 60_000);
160
- it('marks truncated=true when MAX_FILES_TRAVERSED exceeded', async () => {
161
- // Writing 10K+ files in a test would be slow. Skip this empirically;
162
- // we trust the cap via code review. Sanity: cap exists and is > 0.
163
- // (A targeted unit test of the cap behavior is sufficient.)
164
- // This test just confirms the field exists and is false on a small project.
165
- await writeFile('one.ts');
166
- const idx = await indexFiles(root);
167
- expect(idx.truncated).toBe(false);
168
- });
169
- it('sorts by mtime within source files (most recent first)', async () => {
170
- await writeFile('old.ts');
171
- // Force older mtime
172
- const oldPath = path.join(root, 'old.ts');
173
- const past = new Date('2020-01-01').getTime();
174
- await fs.utimes(oldPath, past / 1000, past / 1000);
175
- await writeFile('recent.ts');
176
- const idx = await indexFiles(root);
177
- // recent.ts should be before old.ts within the source-files group.
178
- expect(idx.paths.indexOf('recent.ts')).toBeLessThan(idx.paths.indexOf('old.ts'));
179
- });
180
- });
181
- describe('indexFiles — recursive + paths', () => {
182
- it('uses forward-slash paths regardless of platform', async () => {
183
- await writeFile('a/b/c/deep.ts');
184
- const idx = await indexFiles(root);
185
- expect(idx.paths).toContain('a/b/c/deep.ts');
186
- });
187
- it('counts files across nested directories', async () => {
188
- await writeFile('a.ts');
189
- await writeFile('src/b.ts');
190
- await writeFile('src/lib/c.ts');
191
- await writeFile('docs/notes.md');
192
- const idx = await indexFiles(root);
193
- expect(idx.totalFiles).toBe(4);
194
- });
195
- });