@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,122 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { extractSpecGap } from '../extract-spec-gap.js';
6
- // ESM (`"type": "module"` in package.json): __dirname is NOT defined.
7
- // Derive it via import.meta.url. See cspeach-cli/test/unit/skill-catalog.test.ts.
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const FIXTURE = readFileSync(join(__dirname, '..', '__fixtures__', 'spec-gap-skill-output.md'), 'utf8');
10
- describe('extractSpecGap', () => {
11
- it('parses TL;DR title', () => {
12
- const r = extractSpecGap(FIXTURE);
13
- expect(r.title).toBe('Customer Aging Report');
14
- });
15
- it('extracts at least 7 sections', () => {
16
- const r = extractSpecGap(FIXTURE);
17
- expect(r.content.sections.length).toBeGreaterThanOrEqual(7);
18
- });
19
- it('first section is "Missing Business Questions" (after TL;DR)', () => {
20
- const r = extractSpecGap(FIXTURE);
21
- expect(r.content.sections[0].name).toMatch(/Missing Business Questions/i);
22
- });
23
- it('extracts gaps with stable gap-001, gap-002 ids', () => {
24
- const r = extractSpecGap(FIXTURE);
25
- const allGaps = r.content.sections.flatMap((s) => s.gaps);
26
- expect(allGaps[0].id).toBe('gap-001');
27
- expect(allGaps[1].id).toBe('gap-002');
28
- });
29
- it('parses severity from leading [BLOCKER]/[IMPORTANT]/[NICE TO KNOW]', () => {
30
- const r = extractSpecGap(FIXTURE);
31
- const allGaps = r.content.sections.flatMap((s) => s.gaps);
32
- const blockers = allGaps.filter((g) => g.severity === 'BLOCKER');
33
- const important = allGaps.filter((g) => g.severity === 'IMPORTANT');
34
- const nice = allGaps.filter((g) => g.severity === 'NICE TO KNOW');
35
- expect(blockers.length).toBeGreaterThan(0);
36
- expect(important.length).toBeGreaterThan(0);
37
- expect(nice.length).toBeGreaterThan(0);
38
- expect(blockers.length + important.length + nice.length).toBe(allGaps.length);
39
- });
40
- it('every gap gets a matching interaction.items entry with status="open"', () => {
41
- const r = extractSpecGap(FIXTURE);
42
- const allGaps = r.content.sections.flatMap((s) => s.gaps);
43
- expect(r.items.length).toBe(allGaps.length);
44
- expect(r.items.every((i) => i.status === 'open')).toBe(true);
45
- expect(r.items.every((i) => i.answer === null)).toBe(true);
46
- expect(r.items.every((i) => i.comments.length === 0)).toBe(true);
47
- });
48
- it('extracts inferredDomain from the "Inferred Domain:" line', () => {
49
- const r = extractSpecGap(FIXTURE);
50
- expect(r.content.inferredDomain.length).toBeGreaterThan(0);
51
- });
52
- it('throws on input without TL;DR + sections', () => {
53
- expect(() => extractSpecGap('Hello world')).toThrow(/not a spec-gap output/i);
54
- });
55
- it('parses indented gap lines (real-world model output style)', () => {
56
- const indented = [
57
- '## Spec Gap Analysis — Test',
58
- '',
59
- '### Missing Business Questions',
60
- '',
61
- ' 1. [BLOCKER] What defines "open"?',
62
- ' 2. [IMPORTANT] Currency rate?',
63
- ].join('\n');
64
- const r = extractSpecGap(indented);
65
- expect(r.content.sections).toHaveLength(1);
66
- expect(r.content.sections[0].gaps).toHaveLength(2);
67
- expect(r.content.sections[0].gaps[0].text).toBe('What defines "open"?');
68
- });
69
- it('parses tab-indented gap lines', () => {
70
- const tabbed = [
71
- '## Spec Gap Analysis — Test',
72
- '',
73
- '### Missing Business Questions',
74
- '',
75
- '\t1. [BLOCKER] What defines "open"?',
76
- ].join('\n');
77
- const r = extractSpecGap(tabbed);
78
- expect(r.content.sections[0].gaps).toHaveLength(1);
79
- });
80
- it('parses bold-wrapped severity (real-world variant: `1. **[BLOCKER]** Text`)', () => {
81
- const bolded = [
82
- '## Spec Gap Analysis — Test',
83
- '',
84
- '### Missing Business Questions',
85
- '',
86
- '1. **[BLOCKER]** What is the precise definition of "open"?',
87
- '2. **[IMPORTANT]** Currency rate?',
88
- '3. **[NICE TO KNOW]** Saved variants?',
89
- ].join('\n');
90
- const r = extractSpecGap(bolded);
91
- expect(r.content.sections).toHaveLength(1);
92
- expect(r.content.sections[0].gaps).toHaveLength(3);
93
- expect(r.content.sections[0].gaps[0].severity).toBe('BLOCKER');
94
- expect(r.content.sections[0].gaps[1].severity).toBe('IMPORTANT');
95
- expect(r.content.sections[0].gaps[2].severity).toBe('NICE TO KNOW');
96
- expect(r.content.sections[0].gaps[0].text).toBe('What is the precise definition of "open"?');
97
- });
98
- it('parses indent + bold-wrapped severity together', () => {
99
- const both = [
100
- '## Spec Gap Analysis — Test',
101
- '',
102
- '### Missing Business Questions',
103
- '',
104
- ' 1. **[BLOCKER]** Mixed-style line',
105
- ].join('\n');
106
- const r = extractSpecGap(both);
107
- expect(r.content.sections[0].gaps).toHaveLength(1);
108
- expect(r.content.sections[0].gaps[0].severity).toBe('BLOCKER');
109
- expect(r.content.sections[0].gaps[0].text).toBe('Mixed-style line');
110
- });
111
- it('tags sections with audience derived from name', () => {
112
- const r = extractSpecGap(FIXTURE);
113
- const audienceBySection = Object.fromEntries(r.content.sections.map((s) => [s.name, s.audience]));
114
- expect(audienceBySection['Missing Business Questions']).toBe('business');
115
- expect(audienceBySection['Missing Technical Questions']).toBe('technical');
116
- expect(audienceBySection['Authorization Questions']).toBe('auth');
117
- expect(audienceBySection['Edge Cases Not Covered']).toBe('business');
118
- expect(audienceBySection['Transport and Release Concerns']).toBe('transport');
119
- expect(audienceBySection['Clean Core / API Concerns']).toBe('clean-core');
120
- expect(audienceBySection['Data Assumptions to Confirm']).toBe('data');
121
- });
122
- });
@@ -1,133 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { extractTestCoverage } from '../extract-test-coverage.js';
3
- describe('extractTestCoverage', () => {
4
- const FULL_FIXTURE = `# /abap-test — ZDPR_PAYMENTS test generation
5
-
6
- <!-- csforge:test-coverage-manifest
7
- artefact: test-coverage
8
- title: ZDPR_PAYMENTS — test coverage
9
- detail_path: .abapforge/tests/zdpr-payments/project.json
10
- baseline_path: .cspeach/zdpr-payments-cca-assessment-3467-v1.cspeach.json
11
- baseline_sha256: a7Qj8ibfLTj49kQRTn1DOE_UAExuoBgjhaXxf33+JPY=
12
- transport: S4HK903401
13
- test_classes_generated: 14
14
- methods_added: 87
15
- coverage_pct_before: 12
16
- coverage_pct_after: 78
17
- skipped: 0
18
- failed: 0
19
- tests:
20
- item-001 | ZPAYMENTS | PROG | ZPAYMENTS_TEST | 6 | generated
21
- item-002 | ZPAYMENTS_OLD | PROG | ZPAYMENTS_OLD_TEST | 4 | generated
22
- item-003 | ZFGPAYMENTS | FUGR | ZFGPAYMENTS_TEST | 8 | generated
23
- -->
24
- `;
25
- it('extracts title from manifest', () => {
26
- const out = extractTestCoverage(FULL_FIXTURE);
27
- expect(out.title).toBe('ZDPR_PAYMENTS — test coverage');
28
- });
29
- it('extracts detail + baseline refs', () => {
30
- const out = extractTestCoverage(FULL_FIXTURE);
31
- expect(out.content.detailPath).toBe('.abapforge/tests/zdpr-payments/project.json');
32
- expect(out.content.baselineRef.path).toBe('.cspeach/zdpr-payments-cca-assessment-3467-v1.cspeach.json');
33
- expect(out.content.baselineRef.sha256).toBe('a7Qj8ibfLTj49kQRTn1DOE_UAExuoBgjhaXxf33+JPY=');
34
- });
35
- it('extracts summary with coverage percentages', () => {
36
- const out = extractTestCoverage(FULL_FIXTURE);
37
- expect(out.content.summary).toEqual({
38
- testClassesGenerated: 14,
39
- methodsAdded: 87,
40
- coveragePctBefore: 12,
41
- coveragePctAfter: 78,
42
- skipped: 0,
43
- failed: 0,
44
- });
45
- });
46
- it('parses coverage_pct=- as null (SCMP unavailable case)', () => {
47
- const noCov = FULL_FIXTURE
48
- .replace('coverage_pct_before: 12', 'coverage_pct_before: -')
49
- .replace('coverage_pct_after: 78', 'coverage_pct_after: -');
50
- const out = extractTestCoverage(noCov);
51
- expect(out.content.summary.coveragePctBefore).toBeNull();
52
- expect(out.content.summary.coveragePctAfter).toBeNull();
53
- });
54
- it('parses missing coverage fields as null', () => {
55
- const minimal = `<!-- csforge:test-coverage-manifest
56
- artefact: test-coverage
57
- detail_path: x
58
- baseline_path: y
59
- test_classes_generated: 1
60
- methods_added: 3
61
- -->`;
62
- const out = extractTestCoverage(minimal);
63
- expect(out.content.summary.coveragePctBefore).toBeNull();
64
- expect(out.content.summary.coveragePctAfter).toBeNull();
65
- });
66
- it('extracts tests table with method counts', () => {
67
- const out = extractTestCoverage(FULL_FIXTURE);
68
- expect(out.content.tests).toHaveLength(3);
69
- expect(out.content.tests[0]).toEqual({
70
- id: 'item-001',
71
- targetObjectName: 'ZPAYMENTS',
72
- targetObjectType: 'PROG',
73
- testClassName: 'ZPAYMENTS_TEST',
74
- methodCount: 6,
75
- status: 'generated',
76
- });
77
- });
78
- it('emits one InteractionItem per test entry with matching status', () => {
79
- const out = extractTestCoverage(FULL_FIXTURE);
80
- expect(out.items).toHaveLength(3);
81
- for (const it of out.items) {
82
- expect(it.status).toBe('generated');
83
- expect(it.answer).toBeNull();
84
- }
85
- });
86
- it('coerces unknown status to pending', () => {
87
- const weird = `<!-- csforge:test-coverage-manifest
88
- artefact: test-coverage
89
- detail_path: x
90
- baseline_path: y
91
- tests:
92
- item-001 | ZFOO | PROG | ZFOO_TEST | 2 | half-baked
93
- -->`;
94
- const out = extractTestCoverage(weird);
95
- expect(out.content.tests[0]?.status).toBe('pending');
96
- });
97
- it('throws when artefact field disagrees', () => {
98
- const wrong = `<!-- csforge:test-coverage-manifest
99
- artefact: cca-assessment
100
- detail_path: x
101
- baseline_path: y
102
- -->`;
103
- expect(() => extractTestCoverage(wrong)).toThrow(/expected 'test-coverage'/);
104
- });
105
- it('throws when detail_path is missing', () => {
106
- const noDetail = `<!-- csforge:test-coverage-manifest
107
- artefact: test-coverage
108
- baseline_path: y
109
- -->`;
110
- expect(() => extractTestCoverage(noDetail)).toThrow(/'detail_path'/);
111
- });
112
- it('throws when baseline_path is missing', () => {
113
- const noBaseline = `<!-- csforge:test-coverage-manifest
114
- artefact: test-coverage
115
- detail_path: x
116
- -->`;
117
- expect(() => extractTestCoverage(noBaseline)).toThrow(/'baseline_path'/);
118
- });
119
- it('throws when no manifest block', () => {
120
- expect(() => extractTestCoverage('# nothing')).toThrow(/No <!-- csforge:test-coverage-manifest --> block/);
121
- });
122
- it('handles non-numeric methodCount as 0', () => {
123
- const weird = `<!-- csforge:test-coverage-manifest
124
- artefact: test-coverage
125
- detail_path: x
126
- baseline_path: y
127
- tests:
128
- item-001 | ZFOO | PROG | ZFOO_TEST | notanumber | generated
129
- -->`;
130
- const out = extractTestCoverage(weird);
131
- expect(out.content.tests[0]?.methodCount).toBe(0);
132
- });
133
- });
@@ -1,32 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { titleSlug, projectFilename, shortId } from '../filename.js';
3
- describe('titleSlug', () => {
4
- it('lowercases + hyphenates', () => {
5
- expect(titleSlug('Customer Aging Report')).toBe('customer-aging-report');
6
- });
7
- it('strips punctuation', () => {
8
- expect(titleSlug('FI-AR: Customer Aging!')).toBe('fi-ar-customer-aging');
9
- });
10
- it('collapses whitespace + trims', () => {
11
- expect(titleSlug(' Multi space ')).toBe('multi-space');
12
- });
13
- it('handles unicode by transliterating common cases', () => {
14
- expect(titleSlug('Müller Bericht')).toBe('muller-bericht');
15
- });
16
- it('caps length at 60 chars', () => {
17
- const long = 'A'.repeat(200);
18
- expect(titleSlug(long).length).toBeLessThanOrEqual(60);
19
- });
20
- });
21
- describe('shortId', () => {
22
- it('returns first 4 chars of UUID', () => {
23
- expect(shortId('a1b2c3d4-1111-4111-8111-111111111111')).toBe('a1b2');
24
- });
25
- });
26
- describe('projectFilename', () => {
27
- it('builds canonical filename', () => {
28
- expect(projectFilename({ title: 'Customer Aging Report', artefactType: 'spec-gap',
29
- id: 'a1b2c3d4-1111-4111-8111-111111111111', version: 1 }))
30
- .toBe('customer-aging-report-spec-gap-a1b2-v1.cspeach.json');
31
- });
32
- });
@@ -1,170 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { runPromoteCommand } from '../promote-command.js';
6
- function envelopeFixture(overrides = {}) {
7
- const base = {
8
- schemaVersion: '1.0',
9
- artefactType: 'spec-gap',
10
- id: '11111111-2222-3333-4444-555555555555',
11
- title: 'Customer Aging Report',
12
- createdAt: '2026-05-04T12:00:00Z',
13
- createdBy: { name: 'Laeeq', role: 'consultant' },
14
- lastEditedAt: '2026-05-04T16:00:00Z',
15
- lastEditedBy: { name: 'Sarah', role: 'BA' },
16
- version: 3,
17
- source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '', tokensUsed: 0, model: 'claude-opus-4-7' },
18
- promotedFrom: null,
19
- content: {
20
- inputType: 'User Story', inferredDomain: 'FI',
21
- sections: [{ name: 'G', audience: 'business', gaps: [
22
- { id: 'gap-001', severity: 'BLOCKER', text: 'Q1' },
23
- { id: 'gap-002', severity: 'IMPORTANT', text: 'Q2' },
24
- ] }],
25
- },
26
- interaction: { items: [
27
- { id: 'gap-001', status: 'answered', answer: 'A1', answeredBy: { name: 'S', role: 'BA' }, answeredAt: '2026-05-04T16:00:00Z', comments: [] },
28
- { id: 'gap-002', status: 'answered', answer: 'A2', answeredBy: { name: 'S', role: 'BA' }, answeredAt: '2026-05-04T16:00:00Z', comments: [] },
29
- ] },
30
- history: [
31
- { at: '2026-05-04T12:00:00Z', by: { name: 'L', role: 'consultant' }, action: 'created' },
32
- { at: '2026-05-04T15:00:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: '1 gap answered' },
33
- { at: '2026-05-04T16:00:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: '1 more gap answered' },
34
- ],
35
- };
36
- return { ...base, ...overrides };
37
- }
38
- function writeFixture(path, env) {
39
- writeFileSync(path, JSON.stringify(env, null, 2), 'utf8');
40
- return path;
41
- }
42
- describe('runPromoteCommand', () => {
43
- it('returns snapshot + extendedSkillInput when source is fully answered', async () => {
44
- const dir = mkdtempSync(`${tmpdir()}/cspeach-promote-`);
45
- try {
46
- const path = writeFixture(join(dir, 'src.cspeach.json'), envelopeFixture());
47
- const log = [];
48
- const result = await runPromoteCommand({
49
- sourcePath: path,
50
- targetSkill: 'abap-design',
51
- prompt: async () => 'y',
52
- log: (...lines) => log.push(...lines),
53
- });
54
- expect(result).not.toBeNull();
55
- expect(result.promotedFrom.artefactType).toBe('spec-gap');
56
- expect(result.promotedFrom.version).toBe(3);
57
- expect(result.extendedSkillInput).toContain('Promoted from spec-gap');
58
- expect(result.extendedSkillInput).toContain('gap-001: Q1 → A1');
59
- }
60
- finally {
61
- rmSync(dir, { recursive: true, force: true });
62
- }
63
- });
64
- it('returns null and logs error when source artefactType mismatches', async () => {
65
- const dir = mkdtempSync(`${tmpdir()}/cspeach-promote-`);
66
- try {
67
- const designFixture = {
68
- ...envelopeFixture(),
69
- artefactType: 'design',
70
- content: { objects: [
71
- { id: 'obj-001', name: 'ZCL_X', type: 'CLAS', package: 'P', dependsOn: [] },
72
- ] },
73
- interaction: { items: [
74
- { id: 'obj-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] },
75
- ] },
76
- };
77
- const path = writeFixture(join(dir, 'src.cspeach.json'), designFixture);
78
- const log = [];
79
- const result = await runPromoteCommand({
80
- sourcePath: path,
81
- targetSkill: 'abap-design',
82
- prompt: async () => 'y',
83
- log: (...lines) => log.push(...lines),
84
- });
85
- expect(result).toBeNull();
86
- expect(log.join('\n')).toContain('expects a spec-gap source');
87
- expect(log.join('\n')).toContain('got design');
88
- }
89
- finally {
90
- rmSync(dir, { recursive: true, force: true });
91
- }
92
- });
93
- it('prompts to force-promote when source has open items, returns null on "no"', async () => {
94
- const dir = mkdtempSync(`${tmpdir()}/cspeach-promote-`);
95
- try {
96
- const partial = envelopeFixture({
97
- interaction: { items: [
98
- { id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
99
- { id: 'gap-002', status: 'answered', answer: 'A2', answeredBy: { name: 'S', role: 'BA' }, answeredAt: '2026-05-04T16:00:00Z', comments: [] },
100
- ] },
101
- });
102
- const path = writeFixture(join(dir, 'src.cspeach.json'), partial);
103
- const prompts = [];
104
- const result = await runPromoteCommand({
105
- sourcePath: path,
106
- targetSkill: 'abap-design',
107
- prompt: async (q) => { prompts.push(q); return 'n'; },
108
- log: () => { },
109
- });
110
- expect(result).toBeNull();
111
- expect(prompts.join('\n')).toMatch(/Promote anyway/i);
112
- }
113
- finally {
114
- rmSync(dir, { recursive: true, force: true });
115
- }
116
- });
117
- it('zero-answered source: prompts and respects "no"', async () => {
118
- const dir = mkdtempSync(`${tmpdir()}/cspeach-promote-`);
119
- try {
120
- const empty = envelopeFixture({
121
- interaction: { items: [
122
- { id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
123
- { id: 'gap-002', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
124
- ] },
125
- });
126
- const path = writeFixture(join(dir, 'src.cspeach.json'), empty);
127
- const prompts = [];
128
- const result = await runPromoteCommand({
129
- sourcePath: path,
130
- targetSkill: 'abap-design',
131
- prompt: async (q) => { prompts.push(q); return ''; },
132
- log: () => { },
133
- });
134
- expect(result).toBeNull();
135
- expect(prompts.join('\n')).toMatch(/no answered items/i);
136
- }
137
- finally {
138
- rmSync(dir, { recursive: true, force: true });
139
- }
140
- });
141
- it('estimate target accepts a design source', async () => {
142
- const dir = mkdtempSync(`${tmpdir()}/cspeach-promote-`);
143
- try {
144
- const designEnv = {
145
- ...envelopeFixture(),
146
- artefactType: 'design',
147
- content: { objects: [
148
- { id: 'obj-001', name: 'ZCL_X', type: 'CLAS', package: 'P', dependsOn: [] },
149
- ] },
150
- interaction: { items: [
151
- { id: 'obj-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] },
152
- ] },
153
- };
154
- const path = writeFixture(join(dir, 'src.cspeach.json'), designEnv);
155
- const result = await runPromoteCommand({
156
- sourcePath: path,
157
- targetSkill: 'abap-estimate',
158
- prompt: async () => 'y',
159
- log: () => { },
160
- });
161
- expect(result).not.toBeNull();
162
- expect(result.promotedFrom.artefactType).toBe('design');
163
- expect(result.extendedSkillInput).toContain('Promoted from design');
164
- expect(result.extendedSkillInput).toContain('obj-001: ZCL_X (CLAS) → keep');
165
- }
166
- finally {
167
- rmSync(dir, { recursive: true, force: true });
168
- }
169
- });
170
- });
@@ -1,181 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { buildPromotedFromSnapshot, validatePromotionSource } from '../promote.js';
3
- const SPEC_GAP_SOURCE = {
4
- schemaVersion: '1.0',
5
- artefactType: 'spec-gap',
6
- id: '11111111-2222-3333-4444-555555555555',
7
- title: 'Customer Aging Report',
8
- createdAt: '2026-05-04T12:00:00Z',
9
- createdBy: { name: 'Laeeq', role: 'consultant' },
10
- lastEditedAt: '2026-05-04T16:00:00Z',
11
- lastEditedBy: { name: 'Sarah', role: 'BA' },
12
- version: 3,
13
- source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '', tokensUsed: 0, model: 'claude-opus-4-7' },
14
- promotedFrom: null,
15
- content: {
16
- inputType: 'User Story', inferredDomain: 'FI',
17
- sections: [
18
- { name: 'Missing Business Questions', audience: 'business', gaps: [
19
- { id: 'gap-001', severity: 'BLOCKER', text: 'What defines "open"?' },
20
- { id: 'gap-002', severity: 'IMPORTANT', text: 'Which currency rate?' },
21
- ] },
22
- ],
23
- },
24
- interaction: { items: [
25
- { id: 'gap-001', status: 'answered', answer: 'BSID open items only',
26
- answeredBy: { name: 'Sarah', role: 'BA' }, answeredAt: '2026-05-04T16:00:00Z',
27
- comments: [{ id: 'c1', by: { name: 'Sarah', role: 'BA' }, at: '2026-05-04T16:00:00Z', text: 'Confirmed with finance' }] },
28
- { id: 'gap-002', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
29
- ] },
30
- history: [
31
- { at: '2026-05-04T12:00:00Z', by: { name: 'Laeeq', role: 'consultant' }, action: 'created' },
32
- { at: '2026-05-04T15:00:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: '1 gap answered' },
33
- { at: '2026-05-04T16:00:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: 'no changes' },
34
- ],
35
- };
36
- const DESIGN_SOURCE = {
37
- ...SPEC_GAP_SOURCE,
38
- artefactType: 'design',
39
- content: { objects: [
40
- { id: 'obj-001', name: 'ZCL_AGING', type: 'CLAS', package: 'ZAGING', dependsOn: [] },
41
- { id: 'obj-002', name: 'ZTBL_AGING', type: 'TABL', package: 'ZAGING', dependsOn: ['obj-001'] },
42
- ] },
43
- interaction: { items: [
44
- { id: 'obj-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] },
45
- { id: 'obj-002', status: 'redesign', answer: 'split into two', answeredBy: { name: 'A', role: 'arch' }, answeredAt: '2026-05-04T16:00:00Z', comments: [] },
46
- ] },
47
- };
48
- describe('buildPromotedFromSnapshot — spec-gap source', () => {
49
- it('preserves id, title, artefactType, version', async () => {
50
- const snap = await buildPromotedFromSnapshot(SPEC_GAP_SOURCE);
51
- expect(snap.id).toBe(SPEC_GAP_SOURCE.id);
52
- expect(snap.title).toBe('Customer Aging Report');
53
- expect(snap.artefactType).toBe('spec-gap');
54
- expect(snap.version).toBe(3);
55
- });
56
- it('emits ISO snapshottedAt', async () => {
57
- const snap = await buildPromotedFromSnapshot(SPEC_GAP_SOURCE);
58
- expect(snap.snapshottedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
59
- });
60
- it('computes a 64-char hex sha256 over the canonicalised source', async () => {
61
- const snap = await buildPromotedFromSnapshot(SPEC_GAP_SOURCE);
62
- expect(snap.sha256).toMatch(/^[0-9a-f]{64}$/);
63
- });
64
- it('includes severity for spec-gap items, never comments', async () => {
65
- const snap = await buildPromotedFromSnapshot(SPEC_GAP_SOURCE);
66
- expect(snap.snapshot.items).toHaveLength(2);
67
- expect(snap.snapshot.items[0]).toMatchObject({
68
- id: 'gap-001',
69
- text: 'What defines "open"?',
70
- severity: 'BLOCKER',
71
- status: 'answered',
72
- answer: 'BSID open items only',
73
- });
74
- expect(snap.snapshot.items[1]).toMatchObject({
75
- id: 'gap-002',
76
- text: 'Which currency rate?',
77
- severity: 'IMPORTANT',
78
- status: 'open',
79
- answer: null,
80
- });
81
- for (const it of snap.snapshot.items) {
82
- expect(it).not.toHaveProperty('comments');
83
- }
84
- });
85
- });
86
- describe('buildPromotedFromSnapshot — design source', () => {
87
- it('uses object name + type as text and omits severity', async () => {
88
- const snap = await buildPromotedFromSnapshot(DESIGN_SOURCE);
89
- expect(snap.snapshot.items).toHaveLength(2);
90
- expect(snap.snapshot.items[0]).toMatchObject({
91
- id: 'obj-001',
92
- text: 'ZCL_AGING (CLAS)',
93
- status: 'keep',
94
- answer: null,
95
- });
96
- expect(snap.snapshot.items[0]).not.toHaveProperty('severity');
97
- expect(snap.snapshot.items[1]).toMatchObject({
98
- id: 'obj-002',
99
- text: 'ZTBL_AGING (TABL)',
100
- status: 'redesign',
101
- answer: 'split into two',
102
- });
103
- });
104
- });
105
- describe('buildPromotedFromSnapshot — sha stability', () => {
106
- it('returns the same sha for the same source content (timestamps stripped)', async () => {
107
- const a = { ...SPEC_GAP_SOURCE, lastEditedAt: '2026-05-04T16:00:00Z',
108
- history: [...SPEC_GAP_SOURCE.history.map((h) => ({ ...h, at: '2026-05-04T16:00:00Z' }))] };
109
- const b = { ...SPEC_GAP_SOURCE, lastEditedAt: '2026-05-04T20:00:00Z',
110
- history: [...SPEC_GAP_SOURCE.history.map((h) => ({ ...h, at: '2026-05-04T20:00:00Z' }))] };
111
- const snapA = await buildPromotedFromSnapshot(a);
112
- const snapB = await buildPromotedFromSnapshot(b);
113
- expect(snapA.sha256).toBe(snapB.sha256);
114
- });
115
- });
116
- describe('validatePromotionSource — predecessor type', () => {
117
- it('hard-rejects when source artefactType does not match expected', () => {
118
- const result = validatePromotionSource(SPEC_GAP_SOURCE, 'design');
119
- expect(result.ok).toBe(false);
120
- expect(result.error).toMatch(/expects a design source.*got spec-gap/);
121
- });
122
- it('accepts when source matches expected predecessor', () => {
123
- const result = validatePromotionSource(SPEC_GAP_SOURCE, 'spec-gap');
124
- expect(result.ok).toBe(true);
125
- expect(result.error).toBeUndefined();
126
- });
127
- });
128
- describe('validatePromotionSource — answered-state warnings', () => {
129
- const fullyAnswered = {
130
- ...SPEC_GAP_SOURCE,
131
- interaction: { items: SPEC_GAP_SOURCE.interaction.items.map((i) => ({
132
- ...i, status: 'answered', answer: 'A',
133
- answeredBy: { name: 'X', role: 'BA' }, answeredAt: '2026-05-04T16:00:00Z',
134
- })) },
135
- };
136
- const noneAnswered = {
137
- ...SPEC_GAP_SOURCE,
138
- interaction: { items: SPEC_GAP_SOURCE.interaction.items.map((i) => ({
139
- ...i, status: 'open', answer: null, answeredBy: null, answeredAt: null,
140
- })) },
141
- };
142
- it('emits no warning when source is fully answered', () => {
143
- const r = validatePromotionSource(fullyAnswered, 'spec-gap');
144
- expect(r.ok).toBe(true);
145
- expect(r.warnings).toEqual([]);
146
- });
147
- it('emits ZERO_ANSWERED warning when no items are answered', () => {
148
- const r = validatePromotionSource(noneAnswered, 'spec-gap');
149
- expect(r.ok).toBe(true);
150
- expect(r.warnings).toContainEqual({ kind: 'ZERO_ANSWERED', count: 2 });
151
- });
152
- it('emits PARTIAL_OPEN warning when some items still open', () => {
153
- const r = validatePromotionSource(SPEC_GAP_SOURCE, 'spec-gap'); // 1 open, 1 answered
154
- expect(r.ok).toBe(true);
155
- expect(r.warnings).toContainEqual({ kind: 'PARTIAL_OPEN', openIds: ['gap-002'] });
156
- });
157
- });
158
- describe('validatePromotionSource — multi-source (v0.7)', () => {
159
- // The discriminated union ties artefactType ↔ content shape, but
160
- // validatePromotionSource only inspects artefactType + interaction.items.
161
- // Cast through unknown so tests can flip the artefactType label without
162
- // having to construct a full upgrade-scan / cca-assessment content payload.
163
- function withType(t) {
164
- return { ...SPEC_GAP_SOURCE, artefactType: t };
165
- }
166
- it('accepts a source matching ANY type in the array', () => {
167
- expect(validatePromotionSource(withType('upgrade-scan'), ['upgrade-scan', 'cca-assessment']).ok).toBe(true);
168
- expect(validatePromotionSource(withType('cca-assessment'), ['upgrade-scan', 'cca-assessment']).ok).toBe(true);
169
- });
170
- it('rejects a source matching NONE of the array types', () => {
171
- const r = validatePromotionSource(withType('spec-gap'), ['upgrade-scan', 'cca-assessment']);
172
- expect(r.ok).toBe(false);
173
- expect(r.error).toMatch(/expects one of \[upgrade-scan, cca-assessment\]/);
174
- expect(r.error).toMatch(/got spec-gap/);
175
- });
176
- it('single-element array still produces "a <type>" phrasing in error', () => {
177
- const r = validatePromotionSource(withType('spec-gap'), ['design']);
178
- expect(r.ok).toBe(false);
179
- expect(r.error).toMatch(/expects a design source/);
180
- });
181
- });