@cspeach/cli 0.6.2 → 0.6.4

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 (108) hide show
  1. package/README.md +116 -108
  2. package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
  3. package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
  4. package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
  5. package/dist/agent/__tests__/project-context-injection.test.js +161 -0
  6. package/dist/agent/__tests__/repair-partial.test.js +79 -0
  7. package/dist/agent/__tests__/retry-key.test.js +77 -0
  8. package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
  9. package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
  10. package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
  11. package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
  12. package/dist/agent/__tests__/turn-stream.test.js +100 -0
  13. package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
  14. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
  15. package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
  16. package/dist/agent/providers/__tests__/factory.test.js +33 -0
  17. package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
  18. package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
  19. package/dist/approvals/__tests__/advisory-render.test.js +54 -0
  20. package/dist/auth/__tests__/auth-file.test.js +42 -0
  21. package/dist/auth/__tests__/me.test.js +43 -0
  22. package/dist/classifier/__tests__/client.test.js +70 -0
  23. package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
  24. package/dist/commands/__tests__/login.test.js +152 -0
  25. package/dist/commands/__tests__/logout.test.js +39 -0
  26. package/dist/commands/__tests__/project-context-impact.test.js +173 -0
  27. package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
  28. package/dist/commands/__tests__/whoami.test.js +91 -0
  29. package/dist/config/__tests__/llm-config.test.js +28 -0
  30. package/dist/config/__tests__/shell-exec-config.test.js +76 -0
  31. package/dist/config/__tests__/write-mode.test.js +36 -0
  32. package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
  33. package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
  34. package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
  35. package/dist/index.js +0 -0
  36. package/dist/project-context/__tests__/conventions.test.js +221 -0
  37. package/dist/project-context/__tests__/detect.test.js +177 -0
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
  40. package/dist/project-context/__tests__/domain-cap.test.js +168 -0
  41. package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
  42. package/dist/project-context/__tests__/git.test.js +131 -0
  43. package/dist/project-context/__tests__/index-files.test.js +195 -0
  44. package/dist/project-context/__tests__/index.test.js +133 -0
  45. package/dist/project-context/__tests__/render.test.js +322 -0
  46. package/dist/project-context/__tests__/types.test.js +73 -0
  47. package/dist/projects/__tests__/build.test.js +52 -0
  48. package/dist/projects/__tests__/canonicalize.test.js +47 -0
  49. package/dist/projects/__tests__/email-template.test.js +71 -0
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
  51. package/dist/projects/__tests__/extract-cca.test.js +141 -0
  52. package/dist/projects/__tests__/extract-design.test.js +62 -0
  53. package/dist/projects/__tests__/extract-estimate.test.js +58 -0
  54. package/dist/projects/__tests__/extract-modernize.test.js +127 -0
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
  57. package/dist/projects/__tests__/filename.test.js +32 -0
  58. package/dist/projects/__tests__/promote-command.test.js +170 -0
  59. package/dist/projects/__tests__/promote.test.js +181 -0
  60. package/dist/projects/__tests__/save-command.test.js +216 -0
  61. package/dist/projects/__tests__/save.test.js +41 -0
  62. package/dist/projects/__tests__/status.test.js +208 -0
  63. package/dist/projects/__tests__/types.test.js +41 -0
  64. package/dist/projects/__tests__/validate.test.js +165 -0
  65. package/dist/projects/__tests__/workspace.test.js +337 -0
  66. package/dist/repl/__tests__/current-transport.test.js +43 -0
  67. package/dist/repl/__tests__/diff-display.test.js +59 -0
  68. package/dist/repl/__tests__/file-picker.test.js +97 -0
  69. package/dist/repl/__tests__/rule8-detector.test.js +114 -0
  70. package/dist/repl/__tests__/safety-confirm.test.js +130 -0
  71. package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
  72. package/dist/sap/__tests__/system-info.test.js +344 -0
  73. package/dist/session/__tests__/store-discovery.test.js +114 -0
  74. package/dist/session/__tests__/time-ago.test.js +53 -0
  75. package/dist/skills/__tests__/canonical.test.js +20 -0
  76. package/dist/skills/__tests__/manifest-client.test.js +88 -0
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
  78. package/dist/skills/__tests__/source-managed.test.js +60 -0
  79. package/dist/tools/__tests__/_background-shared.test.js +69 -0
  80. package/dist/tools/__tests__/_command-shared.test.js +59 -0
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
  82. package/dist/tools/__tests__/_flag.test.js +66 -0
  83. package/dist/tools/__tests__/_helpers.js +17 -0
  84. package/dist/tools/__tests__/_web-shared.test.js +143 -0
  85. package/dist/tools/__tests__/agent_run.test.js +166 -0
  86. package/dist/tools/__tests__/approval-advisory.test.js +66 -0
  87. package/dist/tools/__tests__/background_run.test.js +113 -0
  88. package/dist/tools/__tests__/convention_get.test.js +53 -0
  89. package/dist/tools/__tests__/file-edit.test.js +72 -0
  90. package/dist/tools/__tests__/file-read.test.js +81 -0
  91. package/dist/tools/__tests__/file-write.test.js +68 -0
  92. package/dist/tools/__tests__/glob.test.js +128 -0
  93. package/dist/tools/__tests__/grep.test.js +71 -0
  94. package/dist/tools/__tests__/monitor_emit.test.js +96 -0
  95. package/dist/tools/__tests__/playbook_get.test.js +109 -0
  96. package/dist/tools/__tests__/project_context_get.test.js +71 -0
  97. package/dist/tools/__tests__/registry-category.test.js +60 -0
  98. package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
  99. package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
  100. package/dist/tools/__tests__/schedule_create.test.js +85 -0
  101. package/dist/tools/__tests__/shell_exec.test.js +135 -0
  102. package/dist/tools/__tests__/transport-safety.test.js +106 -0
  103. package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
  104. package/dist/tools/__tests__/web_fetch.test.js +329 -0
  105. package/dist/tools/__tests__/web_search.test.js +215 -0
  106. package/dist/tools/__tests__/write-mode.test.js +32 -0
  107. package/dist/ui/__tests__/login-banner.test.js +69 -0
  108. package/package.json +82 -83
@@ -0,0 +1,133 @@
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 { getProjectContext } from '../index.js';
6
+ let root;
7
+ beforeEach(async () => {
8
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-pctx-'));
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('getProjectContext — null cases', () => {
19
+ it('returns null when cwd does not exist and no sap connection given', async () => {
20
+ const ctx = await getProjectContext(path.join(root, 'nope'));
21
+ expect(ctx).toBeNull();
22
+ });
23
+ it('returns a SAP-only context when cwd is bogus but sapConnection is provided', async () => {
24
+ const ctx = await getProjectContext(path.join(root, 'nope'), {
25
+ sapConnection: {
26
+ alias: 'S4H',
27
+ system: { sid: 'S4H', release: '2308', abapVersion: '7.58', platform: 'on-premise', host: null },
28
+ currentTransport: null,
29
+ activeScope: null,
30
+ recentObjects: [],
31
+ },
32
+ });
33
+ expect(ctx).not.toBeNull();
34
+ expect(ctx?.type).toBe('unknown');
35
+ expect(ctx?.sapConnection?.alias).toBe('S4H');
36
+ expect(ctx?.git.isRepo).toBe(false);
37
+ expect(ctx?.index.totalFiles).toBe(0);
38
+ });
39
+ });
40
+ describe('getProjectContext — unknown project', () => {
41
+ it('returns an unknown context for a directory that matches no project type', async () => {
42
+ await writeFile('random.txt', 'just a file');
43
+ const ctx = await getProjectContext(root);
44
+ expect(ctx).not.toBeNull();
45
+ expect(ctx?.type).toBe('unknown');
46
+ expect(ctx?.root).toBe(root);
47
+ expect(ctx?.domainObjects).toEqual({ type: 'unknown' });
48
+ expect(ctx?.index.totalFiles).toBe(1);
49
+ expect(ctx?.detectedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
50
+ });
51
+ });
52
+ describe('getProjectContext — abapgit project', () => {
53
+ it('assembles a full abapgit context end-to-end', async () => {
54
+ await writeFile('.abapgit.xml', '<xml/>');
55
+ await writeFile('src/zfi/zfi_payments/zcl_pay.clas.abap', '');
56
+ await writeFile('src/zfi/zfi_payments/zif_pay.intf.abap', '');
57
+ await writeFile('CLAUDE.md', '# project conventions\n\nUse ZCL_ prefix.\n');
58
+ const ctx = await getProjectContext(root);
59
+ expect(ctx).not.toBeNull();
60
+ expect(ctx.type).toBe('abapgit');
61
+ expect(ctx.conventions.freeText).toContain('ZCL_ prefix');
62
+ if (ctx.domainObjects.type !== 'abapgit')
63
+ throw new Error('wrong domain variant');
64
+ expect(ctx.domainObjects.objects.map((o) => o.name).sort()).toEqual([
65
+ 'ZCL_PAY',
66
+ 'ZIF_PAY',
67
+ ]);
68
+ expect(ctx.index.totalFiles).toBeGreaterThan(0);
69
+ });
70
+ });
71
+ describe('getProjectContext — fiori-ui5 project', () => {
72
+ it('assembles a Fiori UI5 context with cross-linked controllers + views', async () => {
73
+ await writeFile('webapp/manifest.json', JSON.stringify({
74
+ 'sap.ui5': {
75
+ routing: {
76
+ routes: [{ name: 'home', pattern: '', target: 'home' }],
77
+ },
78
+ },
79
+ }));
80
+ await writeFile('ui5.yaml', 'specVersion: "3.0"\n');
81
+ await writeFile('webapp/controller/Home.controller.js', `Controller.extend("myapp.controller.Home", {});`);
82
+ await writeFile('webapp/view/Home.view.xml', `<mvc:View controllerName="myapp.controller.Home" />`);
83
+ const ctx = await getProjectContext(root);
84
+ expect(ctx?.type).toBe('fiori-ui5');
85
+ if (ctx?.domainObjects.type !== 'fiori-ui5')
86
+ throw new Error('wrong domain variant');
87
+ expect(ctx.domainObjects.routes).toHaveLength(1);
88
+ expect(ctx.domainObjects.controllers[0].viewPath).toBe('webapp/view/Home.view.xml');
89
+ expect(ctx.domainObjects.views[0].controllerPath).toBe('webapp/controller/Home.controller.js');
90
+ });
91
+ });
92
+ describe('getProjectContext — cap-node project', () => {
93
+ it('assembles a CAP context with services + entities', async () => {
94
+ await writeFile('package.json', JSON.stringify({ dependencies: { '@sap/cds': '^7.0.0' } }));
95
+ await writeFile('db/schema.cds', 'entity Books { key ID : Integer; }');
96
+ await writeFile('srv/cat.cds', 'service CatalogService { entity Books; }');
97
+ const ctx = await getProjectContext(root);
98
+ expect(ctx?.type).toBe('cap-node');
99
+ if (ctx?.domainObjects.type !== 'cap-node')
100
+ throw new Error('wrong domain variant');
101
+ expect(ctx.domainObjects.services.map((s) => s.name)).toEqual(['CatalogService']);
102
+ expect(ctx.domainObjects.entities.map((e) => e.name).sort()).toContain('Books');
103
+ });
104
+ });
105
+ describe('getProjectContext — mixed (mta.yaml)', () => {
106
+ it('returns a mixed-type stub for now (recursive sub-context is Phase 2+)', async () => {
107
+ await writeFile('mta.yaml', 'ID: my.mta\nversion: 1.0.0\nmodules: []\n');
108
+ const ctx = await getProjectContext(root);
109
+ expect(ctx?.type).toBe('mixed');
110
+ expect(ctx?.domainObjects).toEqual({ type: 'mixed', modules: [] });
111
+ });
112
+ });
113
+ describe('getProjectContext — sapConnection passthrough', () => {
114
+ it('attaches sapConnection to a disk project when provided', async () => {
115
+ await writeFile('.abapgit.xml', '<xml/>');
116
+ await writeFile('src/zhello.prog.abap', '');
117
+ const sapConnection = {
118
+ alias: 'PROD_DEV',
119
+ system: { sid: 'PRD', release: '2308', abapVersion: '7.58', platform: 'on-premise', host: null },
120
+ currentTransport: 'PRDK900456',
121
+ activeScope: null,
122
+ recentObjects: [],
123
+ };
124
+ const ctx = await getProjectContext(root, { sapConnection });
125
+ expect(ctx?.sapConnection).toBe(sapConnection);
126
+ expect(ctx?.type).toBe('abapgit');
127
+ });
128
+ it('defaults sapConnection to null when option omitted', async () => {
129
+ await writeFile('random.txt');
130
+ const ctx = await getProjectContext(root);
131
+ expect(ctx?.sapConnection).toBeNull();
132
+ });
133
+ });
@@ -0,0 +1,322 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderProjectContext } from '../render.js';
3
+ function baseContext(overrides = {}) {
4
+ return {
5
+ type: 'unknown',
6
+ root: '/projects/sample',
7
+ conventions: {
8
+ freeText: null,
9
+ namingPatterns: { classes: [], interfaces: [], cdsViews: [] },
10
+ style: { indentSize: 2, indentChar: 'space', eol: 'lf' },
11
+ },
12
+ index: {
13
+ totalFiles: 0,
14
+ byExtension: {},
15
+ topLevelDirs: [],
16
+ paths: [],
17
+ truncated: false,
18
+ },
19
+ entryPoints: [],
20
+ domainObjects: { type: 'unknown' },
21
+ git: { isRepo: false, branch: null, headSha: null, dirtyFileCount: 0, recentCommits: [] },
22
+ sapConnection: null,
23
+ detectedAt: '2026-05-10T10:00:00.000Z',
24
+ ...overrides,
25
+ };
26
+ }
27
+ describe('renderProjectContext — null handling', () => {
28
+ it('returns empty string for null context', () => {
29
+ expect(renderProjectContext(null)).toBe('');
30
+ });
31
+ it('renders a minimal unknown context as a valid block', () => {
32
+ const out = renderProjectContext(baseContext());
33
+ expect(out).toContain('<project_context type="unknown">');
34
+ expect(out).toContain('</project_context>');
35
+ expect(out).toContain('<root>/projects/sample</root>');
36
+ expect(out).toContain('<git is_repo="false" />');
37
+ expect(out).toContain('<domain_objects type="unknown" />');
38
+ });
39
+ });
40
+ describe('renderProjectContext — conventions', () => {
41
+ it('renders style + naming + truncated freeText', () => {
42
+ const longText = 'A'.repeat(2000);
43
+ const out = renderProjectContext(baseContext({
44
+ conventions: {
45
+ freeText: longText,
46
+ namingPatterns: { classes: ['ZCL_'], interfaces: ['ZIF_'], cdsViews: ['ZI_', 'ZC_'] },
47
+ style: { indentSize: 4, indentChar: 'tab', eol: 'crlf' },
48
+ },
49
+ }));
50
+ expect(out).toContain('<style indent="4 tabs" eol="CRLF" />');
51
+ expect(out).toContain('<classes>ZCL_</classes>');
52
+ expect(out).toContain('<cds_views>ZI_, ZC_</cds_views>');
53
+ expect(out).toContain('truncated');
54
+ // freeText should be capped — the rendered output must be far less
55
+ // than the 2000 chars of input.
56
+ expect(out.length).toBeLessThan(2000 + 500);
57
+ });
58
+ it('renders the default 2-space indent in unambiguous form (regression)', () => {
59
+ // 2026-05-10 dogfooding: the earlier "2sp" packed-token form was
60
+ // mis-parsed by the model as "1-space indent." The human-readable
61
+ // "2 spaces" form leaves no room for misinterpretation.
62
+ const out = renderProjectContext(baseContext()); // default convention is 2-space LF
63
+ expect(out).toContain('<style indent="2 spaces" eol="LF" />');
64
+ expect(out).not.toContain('"2sp"');
65
+ });
66
+ it('uses singular "space" / "tab" for indent size 1', () => {
67
+ const out = renderProjectContext(baseContext({
68
+ conventions: {
69
+ freeText: null,
70
+ namingPatterns: { classes: [], interfaces: [], cdsViews: [] },
71
+ style: { indentSize: 1, indentChar: 'tab', eol: 'lf' },
72
+ },
73
+ }));
74
+ expect(out).toContain('<style indent="1 tab" eol="LF" />');
75
+ });
76
+ });
77
+ describe('renderProjectContext — git', () => {
78
+ it('renders branch + sha + dirty + recent commits when in a repo', () => {
79
+ const out = renderProjectContext(baseContext({
80
+ git: {
81
+ isRepo: true,
82
+ branch: 'main',
83
+ headSha: 'abcd1234',
84
+ dirtyFileCount: 3,
85
+ recentCommits: ['1111111 first', '2222222 second'],
86
+ },
87
+ }));
88
+ expect(out).toContain('<git is_repo="true">');
89
+ expect(out).toContain('<branch>main</branch>');
90
+ expect(out).toContain('<head_sha>abcd1234</head_sha>');
91
+ expect(out).toContain('<dirty_files>3</dirty_files>');
92
+ expect(out).toContain('1111111 first');
93
+ expect(out).toContain('2222222 second');
94
+ });
95
+ });
96
+ describe('renderProjectContext — file index', () => {
97
+ it('renders top-level dirs + extension summary + capped paths', () => {
98
+ const paths = Array.from({ length: 200 }, (_, i) => `src/file-${i}.ts`);
99
+ const out = renderProjectContext(baseContext({
100
+ index: {
101
+ totalFiles: 200,
102
+ byExtension: { '.ts': 200, '.md': 5 },
103
+ topLevelDirs: ['src', 'docs'],
104
+ paths,
105
+ truncated: false,
106
+ },
107
+ }));
108
+ expect(out).toContain('<top_level_dirs>src, docs</top_level_dirs>');
109
+ expect(out).toContain('.ts=200');
110
+ expect(out).toContain('src/file-0.ts');
111
+ // Paths cap is 60 — must include a "more" marker.
112
+ expect(out).toContain('more — search via glob');
113
+ expect(out).not.toContain('src/file-199.ts');
114
+ });
115
+ it('emits truncated="true" attribute when index truncation flag is set', () => {
116
+ const out = renderProjectContext(baseContext({
117
+ index: { totalFiles: 12000, byExtension: {}, topLevelDirs: [], paths: [], truncated: true },
118
+ }));
119
+ expect(out).toContain('<file_index total="12000" truncated="true">');
120
+ });
121
+ });
122
+ describe('renderProjectContext — domain renderers', () => {
123
+ it('renders fiori-ui5 with controllers/views/routes/i18n', () => {
124
+ const out = renderProjectContext(baseContext({
125
+ type: 'fiori-ui5',
126
+ domainObjects: {
127
+ type: 'fiori-ui5',
128
+ controllers: [
129
+ { name: 'Worklist', path: 'webapp/controller/Worklist.controller.js', viewPath: 'webapp/view/Worklist.view.xml', i18nKeys: ['title'] },
130
+ ],
131
+ views: [
132
+ { name: 'Worklist', path: 'webapp/view/Worklist.view.xml', controllerPath: 'webapp/controller/Worklist.controller.js', boundModelPaths: [] },
133
+ ],
134
+ routes: [{ name: 'home', pattern: '', targets: ['main'] }],
135
+ i18nKeys: ['title', 'description'],
136
+ },
137
+ }));
138
+ expect(out).toContain('<domain_objects type="fiori-ui5">');
139
+ expect(out).toContain('<route name="home"');
140
+ expect(out).toContain('<controller name="Worklist"');
141
+ expect(out).toContain('view="webapp/view/Worklist.view.xml"');
142
+ expect(out).toContain('<i18n_keys>title, description</i18n_keys>');
143
+ });
144
+ it('renders cap-node with services + entities', () => {
145
+ const out = renderProjectContext(baseContext({
146
+ type: 'cap-node',
147
+ domainObjects: {
148
+ type: 'cap-node',
149
+ services: [{ name: 'CatalogService', path: 'srv/cat.cds', entities: ['Books', 'Authors'] }],
150
+ entities: [
151
+ { name: 'Books', path: 'db/schema.cds', source: null },
152
+ { name: 'External', path: 'db/ext.cds', source: 'external-table' },
153
+ ],
154
+ },
155
+ }));
156
+ expect(out).toContain('<service name="CatalogService"');
157
+ expect(out).toContain('entities="Books,Authors"');
158
+ expect(out).toContain('<entity name="Books"');
159
+ expect(out).toContain('source="external-table"');
160
+ });
161
+ it('renders abapgit with package + cap marker', () => {
162
+ const objects = Array.from({ length: 100 }, (_, i) => ({
163
+ name: `ZCL_OBJ_${i.toString().padStart(3, '0')}`,
164
+ type: 'CLAS',
165
+ path: `src/obj-${i}.clas.abap`,
166
+ package: 'ZTEST',
167
+ }));
168
+ const out = renderProjectContext(baseContext({
169
+ type: 'abapgit',
170
+ domainObjects: { type: 'abapgit', objects },
171
+ }));
172
+ expect(out).toContain('<domain_objects type="abapgit" total="100">');
173
+ expect(out).toContain('<object name="ZCL_OBJ_000"');
174
+ expect(out).toContain('package="ZTEST"');
175
+ expect(out).toContain('20 more objects — search via glob');
176
+ expect(out).not.toContain('ZCL_OBJ_099');
177
+ });
178
+ it('renders abap-cloud with behavior definitions promoted', () => {
179
+ const out = renderProjectContext(baseContext({
180
+ type: 'abap-cloud',
181
+ domainObjects: {
182
+ type: 'abap-cloud',
183
+ objects: [
184
+ { name: 'ZR_ORDER', type: 'BDEF', path: 'src/zr_order.bdef.asbdef', package: null },
185
+ { name: 'ZBP_ZR_ORDER', type: 'CLAS', path: 'src/zbp_zr_order.clas.abap', package: null },
186
+ ],
187
+ behaviorDefinitions: ['ZR_ORDER'],
188
+ },
189
+ }));
190
+ expect(out).toContain('behavior_definitions="1"');
191
+ expect(out).toContain('<bdefs>ZR_ORDER</bdefs>');
192
+ expect(out).toContain('<object name="ZBP_ZR_ORDER"');
193
+ });
194
+ it('renders mixed type with module count', () => {
195
+ const out = renderProjectContext(baseContext({
196
+ type: 'mixed',
197
+ domainObjects: { type: 'mixed', modules: [] },
198
+ }));
199
+ expect(out).toContain('<domain_objects type="mixed" modules="0" />');
200
+ });
201
+ });
202
+ describe('renderProjectContext — sap connection', () => {
203
+ it('renders sap_connection block when connection is present', () => {
204
+ const out = renderProjectContext(baseContext({
205
+ sapConnection: {
206
+ alias: 'S4H',
207
+ system: { sid: 'S4H', release: '2308', abapVersion: '7.58', platform: 'on-premise', host: 'sap.example.com' },
208
+ currentTransport: 'S4HK900123',
209
+ activeScope: { package: 'ZFI_PAYMENTS', object: { name: 'ZCL_PAY', type: 'CLAS' } },
210
+ recentObjects: [
211
+ { name: 'ZCL_PAY', type: 'CLAS', lastTouchedAt: '2026-05-10T10:00:00Z', lastAction: 'read' },
212
+ ],
213
+ },
214
+ }));
215
+ expect(out).toContain('<sap_connection alias="S4H">');
216
+ expect(out).toContain('sid="S4H"');
217
+ expect(out).toContain('release="2308"');
218
+ expect(out).toContain('<current_transport>S4HK900123</current_transport>');
219
+ expect(out).toContain('<active_package>ZFI_PAYMENTS</active_package>');
220
+ expect(out).toContain('<active_object name="ZCL_PAY"');
221
+ expect(out).toContain('<object name="ZCL_PAY" type="CLAS" action="read" />');
222
+ });
223
+ });
224
+ describe('renderProjectContext — escaping', () => {
225
+ it('escapes XML special characters in values', () => {
226
+ const out = renderProjectContext(baseContext({
227
+ root: '/path/with <angle> & "quotes"',
228
+ }));
229
+ expect(out).toContain('/path/with &lt;angle&gt; &amp; "quotes"');
230
+ });
231
+ it('escapes XML inside conventions.freeText (realistic CLAUDE.md content)', () => {
232
+ // This very repo's CLAUDE.md contains literal XML snippets like
233
+ // <asx:values>, <atc:run>, <objectSet>. Without escaping, those
234
+ // would break <project_context> well-formedness when the freeText
235
+ // body is spliced into the system prompt.
236
+ const realFreeText = [
237
+ '## Critical Patterns',
238
+ '',
239
+ '**Lock handle parsing:** SAP returns `<LOCK_HANDLE>` (uppercase) inside `<asx:values><DATA>`.',
240
+ '**ATC run XML:** Use `<objectSet kind="inclusive">` (no namespace prefix). Use `<atc:run>` not `<atcinfo:run>`.',
241
+ 'Special chars: & < > should all survive escaping.',
242
+ ].join('\n');
243
+ const out = renderProjectContext(baseContext({
244
+ conventions: {
245
+ freeText: realFreeText,
246
+ namingPatterns: { classes: [], interfaces: [], cdsViews: [] },
247
+ style: { indentSize: 2, indentChar: 'space', eol: 'lf' },
248
+ },
249
+ }));
250
+ // The literal XML must NOT appear in the output as raw tags — it
251
+ // must be entity-escaped so it can't close <freeText> early.
252
+ expect(out).not.toContain('<asx:values>');
253
+ expect(out).not.toContain('<atc:run>');
254
+ expect(out).not.toContain('<objectSet kind=');
255
+ expect(out).toContain('&lt;asx:values&gt;');
256
+ expect(out).toContain('&lt;atc:run&gt;');
257
+ // Bare ampersand must also escape.
258
+ expect(out).toContain('&amp; &lt; &gt;');
259
+ // The opening/closing tags of OUR block remain intact.
260
+ expect(out).toContain('<freeText>');
261
+ expect(out).toContain('</freeText>');
262
+ });
263
+ it('escapes XML in namingPatterns + topLevelDirs + by_extension', () => {
264
+ const out = renderProjectContext(baseContext({
265
+ conventions: {
266
+ freeText: null,
267
+ // Synthetic but representative: a custom override could put
268
+ // anything here; we must not trust it inside element bodies.
269
+ namingPatterns: { classes: ['Z<bad>_'], interfaces: ['Z&_'], cdsViews: [] },
270
+ style: { indentSize: 2, indentChar: 'space', eol: 'lf' },
271
+ },
272
+ index: {
273
+ totalFiles: 1,
274
+ byExtension: { '.<x>': 1, '.&y': 1 },
275
+ topLevelDirs: ['weird<dir>', 'a&b'],
276
+ paths: [],
277
+ truncated: false,
278
+ },
279
+ }));
280
+ expect(out).not.toContain('Z<bad>_');
281
+ expect(out).toContain('Z&lt;bad&gt;_');
282
+ expect(out).toContain('Z&amp;_');
283
+ expect(out).toContain('weird&lt;dir&gt;');
284
+ expect(out).toContain('a&amp;b');
285
+ expect(out).toContain('.&lt;x&gt;=1');
286
+ expect(out).toContain('.&amp;y=1');
287
+ });
288
+ });
289
+ describe('renderProjectContext — rough size budget', () => {
290
+ it('stays under ~13000 chars on a high-end project (~3.25K tokens)', () => {
291
+ const ctrls = Array.from({ length: 60 }, (_, i) => ({
292
+ name: `Ctl${i}`,
293
+ path: `webapp/controller/Ctl${i}.controller.js`,
294
+ viewPath: `webapp/view/Ctl${i}.view.xml`,
295
+ i18nKeys: ['k1', 'k2'],
296
+ }));
297
+ const views = Array.from({ length: 60 }, (_, i) => ({
298
+ name: `Ctl${i}`,
299
+ path: `webapp/view/Ctl${i}.view.xml`,
300
+ controllerPath: `webapp/controller/Ctl${i}.controller.js`,
301
+ boundModelPaths: ['/data/items'],
302
+ }));
303
+ const routes = Array.from({ length: 60 }, (_, i) => ({
304
+ name: `route${i}`,
305
+ pattern: `r/${i}`,
306
+ targets: [`t${i}`],
307
+ }));
308
+ const i18n = Array.from({ length: 200 }, (_, i) => `key${i}`);
309
+ const paths = Array.from({ length: 500 }, (_, i) => `webapp/file-${i}.js`);
310
+ const out = renderProjectContext(baseContext({
311
+ type: 'fiori-ui5',
312
+ conventions: {
313
+ freeText: 'B'.repeat(500),
314
+ namingPatterns: { classes: [], interfaces: [], cdsViews: [] },
315
+ style: { indentSize: 2, indentChar: 'space', eol: 'lf' },
316
+ },
317
+ index: { totalFiles: 500, byExtension: { '.js': 500 }, topLevelDirs: ['webapp'], paths, truncated: false },
318
+ domainObjects: { type: 'fiori-ui5', controllers: ctrls, views, routes, i18nKeys: i18n },
319
+ }));
320
+ expect(out.length).toBeLessThan(13000);
321
+ });
322
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, expectTypeOf } from 'vitest';
2
+ import { getProjectContext } from '../index.js';
3
+ describe('project-context types — Phase 1A scaffolding', () => {
4
+ it('ProjectType enumerates the six supported project shapes', () => {
5
+ expectTypeOf().toEqualTypeOf();
6
+ });
7
+ it('ProjectConventions has freeText, namingPatterns, style', () => {
8
+ expectTypeOf().toMatchTypeOf();
9
+ });
10
+ it('FileIndex caps + truncation flag are part of the shape', () => {
11
+ expectTypeOf().toMatchTypeOf();
12
+ });
13
+ it('DomainObjectIndex is type-discriminated', () => {
14
+ // Each project type has its own domain-object shape. Compile-time
15
+ // discrimination prevents callers from accidentally accessing
16
+ // controllers[] on a CAP project or services[] on a Fiori project.
17
+ const fiori = {
18
+ type: 'fiori-ui5',
19
+ controllers: [],
20
+ views: [],
21
+ routes: [],
22
+ i18nKeys: [],
23
+ };
24
+ expect(fiori.type).toBe('fiori-ui5');
25
+ const cap = {
26
+ type: 'cap-node',
27
+ services: [],
28
+ entities: [],
29
+ };
30
+ expect(cap.type).toBe('cap-node');
31
+ const abapgit = {
32
+ type: 'abapgit',
33
+ objects: [],
34
+ };
35
+ expect(abapgit.type).toBe('abapgit');
36
+ const cloud = {
37
+ type: 'abap-cloud',
38
+ objects: [],
39
+ behaviorDefinitions: [],
40
+ };
41
+ expect(cloud.type).toBe('abap-cloud');
42
+ const unknown = { type: 'unknown' };
43
+ expect(unknown.type).toBe('unknown');
44
+ });
45
+ it('GitState carries branch + dirty count + recent commits', () => {
46
+ expectTypeOf().toMatchTypeOf();
47
+ });
48
+ it('ProjectContext aggregates everything + a detectedAt timestamp', () => {
49
+ expectTypeOf().toMatchTypeOf();
50
+ });
51
+ it('ProjectContext.sapConnection is nullable — classical ABAP live work surfaces here', async () => {
52
+ // Live-SAP classical ABAP work has no on-disk project but DOES
53
+ // have a project: the SAP system + active package + recent objects.
54
+ // sapConnection is where that lives. Most CSPeach users today are
55
+ // in this mode — connected to a system, no disk project.
56
+ const { SapConnectionContext } = await import('../types.js')
57
+ .then((m) => ({ SapConnectionContext: null }))
58
+ .catch(() => ({ SapConnectionContext: null }));
59
+ expectTypeOf().toMatchTypeOf();
60
+ expect(SapConnectionContext).toBeNull(); // The placeholder import; just exercising the module load.
61
+ });
62
+ });
63
+ describe('getProjectContext — null contract', () => {
64
+ // Phase 1F implementation. Detailed behavior tests live in index.test.ts.
65
+ // These tests pin down the null-safety invariant only.
66
+ it('returns null when cwd is unreadable / does not exist', async () => {
67
+ expect(await getProjectContext('/nonexistent/path/that/cannot/exist')).toBeNull();
68
+ });
69
+ it('never throws on hostile input — null contract is the safety net', async () => {
70
+ await expect(getProjectContext('\0\0\0')).resolves.toBeNull();
71
+ await expect(getProjectContext('/nonexistent/path/that/cannot/exist')).resolves.toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildEnvelope } from '../build.js';
3
+ const SAMPLE_EXTRACT = {
4
+ title: 'Customer Aging Report',
5
+ content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [
6
+ { name: 'Missing Business Questions', audience: 'business', gaps: [
7
+ { id: 'gap-001', severity: 'BLOCKER', text: 'What defines "open"?' },
8
+ ] },
9
+ ] },
10
+ items: [{ id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] }],
11
+ };
12
+ describe('buildEnvelope', () => {
13
+ it('assembles a v1 envelope with version=1, history=[created]', () => {
14
+ const env = buildEnvelope({
15
+ artefactType: 'spec-gap',
16
+ extract: SAMPLE_EXTRACT,
17
+ author: { name: 'Laeeq', role: 'consultant' },
18
+ source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 5000, model: 'claude-opus-4-7' },
19
+ });
20
+ expect(env.schemaVersion).toBe('1.0');
21
+ expect(env.artefactType).toBe('spec-gap');
22
+ expect(env.title).toBe('Customer Aging Report');
23
+ expect(env.version).toBe(1);
24
+ expect(env.history).toHaveLength(1);
25
+ expect(env.history[0].action).toBe('created');
26
+ expect(env.history[0].by).toEqual({ name: 'Laeeq', role: 'consultant' });
27
+ expect(env.createdBy).toEqual(env.lastEditedBy);
28
+ expect(env.createdAt).toBe(env.lastEditedAt);
29
+ expect(env.promotedFrom).toBeNull();
30
+ expect(env.interaction.items).toHaveLength(1);
31
+ });
32
+ it('generates a UUID v4 id', () => {
33
+ const env = buildEnvelope({
34
+ artefactType: 'spec-gap',
35
+ extract: SAMPLE_EXTRACT,
36
+ author: { name: 'Laeeq', role: 'consultant' },
37
+ source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 5000, model: 'claude-opus-4-7' },
38
+ });
39
+ expect(env.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
40
+ });
41
+ it('produces a validatable envelope', async () => {
42
+ const { validateEnvelope } = await import('../validate.js');
43
+ const env = buildEnvelope({
44
+ artefactType: 'spec-gap',
45
+ extract: SAMPLE_EXTRACT,
46
+ author: { name: 'Laeeq', role: 'consultant' },
47
+ source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 5000, model: 'claude-opus-4-7' },
48
+ });
49
+ const result = validateEnvelope(env);
50
+ expect(result.ok).toBe(true);
51
+ });
52
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { canonicalSha256 } from '../canonicalize.js';
3
+ const baseEnv = {
4
+ schemaVersion: '1.0',
5
+ artefactType: 'spec-gap',
6
+ id: '11111111-1111-4111-8111-111111111111',
7
+ title: 'Customer Aging Report',
8
+ createdAt: '2026-05-03T12:00:00Z',
9
+ createdBy: { name: 'Laeeq', role: 'consultant' },
10
+ lastEditedAt: '2026-05-04T10:00:00Z',
11
+ lastEditedBy: { name: 'Laeeq', role: 'consultant' },
12
+ version: 1,
13
+ source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 100, model: 'm' },
14
+ promotedFrom: null,
15
+ content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [] },
16
+ interaction: { items: [] },
17
+ history: [{ at: '2026-05-03T12:00:00Z', by: { name: 'Laeeq', role: 'consultant' }, action: 'created' }],
18
+ };
19
+ describe('canonicalSha256', () => {
20
+ it('produces a 64-char hex hash', async () => {
21
+ const hash = await canonicalSha256(baseEnv);
22
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
23
+ });
24
+ it('is stable across object key order', async () => {
25
+ const reordered = { ...baseEnv, version: 1, schemaVersion: '1.0' };
26
+ expect(await canonicalSha256(baseEnv)).toBe(await canonicalSha256(reordered));
27
+ });
28
+ it('ignores changes to lastEditedAt', async () => {
29
+ const sameContent = { ...baseEnv, lastEditedAt: '2099-12-31T23:59:59Z' };
30
+ expect(await canonicalSha256(baseEnv)).toBe(await canonicalSha256(sameContent));
31
+ });
32
+ it('ignores changes to history[*].at', async () => {
33
+ const sameContent = {
34
+ ...baseEnv,
35
+ history: [{ at: '2099-12-31T23:59:59Z', by: { name: 'Laeeq', role: 'consultant' }, action: 'created' }],
36
+ };
37
+ expect(await canonicalSha256(baseEnv)).toBe(await canonicalSha256(sameContent));
38
+ });
39
+ it('changes when answer text changes', async () => {
40
+ const withAnswer = {
41
+ ...baseEnv,
42
+ interaction: { items: [{ id: 'gap-001', status: 'answered', answer: 'BSID',
43
+ answeredBy: { name: 'Sarah', role: 'BA' }, answeredAt: '2026-05-04T10:00:00Z', comments: [] }] },
44
+ };
45
+ expect(await canonicalSha256(baseEnv)).not.toBe(await canonicalSha256(withAnswer));
46
+ });
47
+ });