@cleocode/core 2026.4.11 → 2026.4.13

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 (184) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +9 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +96 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/backup-crypto.test.ts +101 -0
  128. package/src/store/__tests__/backup-pack.test.ts +491 -0
  129. package/src/store/__tests__/backup-unpack.test.ts +298 -0
  130. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  131. package/src/store/__tests__/global-salt.test.ts +195 -0
  132. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  133. package/src/store/__tests__/regenerators.test.ts +234 -0
  134. package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
  135. package/src/store/__tests__/restore-json-merge.test.ts +521 -0
  136. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  137. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  138. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  139. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  140. package/src/store/__tests__/t310-readiness.test.ts +111 -0
  141. package/src/store/__tests__/t311-integration.test.ts +661 -0
  142. package/src/store/agent-registry-accessor.ts +847 -140
  143. package/src/store/api-key-kdf.ts +104 -0
  144. package/src/store/backup-crypto.ts +209 -0
  145. package/src/store/backup-pack.ts +739 -0
  146. package/src/store/backup-unpack.ts +583 -0
  147. package/src/store/conduit-sqlite.ts +655 -0
  148. package/src/store/global-salt.ts +175 -0
  149. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  150. package/src/store/regenerators.ts +243 -0
  151. package/src/store/restore-conflict-report.ts +317 -0
  152. package/src/store/restore-json-merge.ts +653 -0
  153. package/src/store/signaldock-sqlite.ts +431 -254
  154. package/src/store/sqlite-backup.ts +185 -10
  155. package/src/store/t310-readiness.ts +119 -0
  156. package/src/system/backup.ts +2 -62
  157. package/src/system/runtime.ts +4 -6
  158. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  159. package/src/tasks/add.ts +99 -9
  160. package/src/tasks/complete.ts +4 -1
  161. package/src/tasks/find.ts +4 -1
  162. package/src/tasks/labels.ts +4 -1
  163. package/src/tasks/relates.ts +16 -4
  164. package/src/tasks/show.ts +4 -1
  165. package/src/tasks/update.ts +32 -3
  166. package/src/validation/__tests__/error-hints.test.ts +97 -0
  167. package/src/validation/engine.ts +16 -1
  168. package/src/validation/param-utils.ts +10 -7
  169. package/src/validation/protocols/_shared.ts +14 -6
  170. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  171. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  172. package/src/validation/protocols/cant/consensus.cant +74 -0
  173. package/src/validation/protocols/cant/contribution.cant +82 -0
  174. package/src/validation/protocols/cant/decomposition.cant +92 -0
  175. package/src/validation/protocols/cant/implementation.cant +67 -0
  176. package/src/validation/protocols/cant/provenance.cant +88 -0
  177. package/src/validation/protocols/cant/release.cant +96 -0
  178. package/src/validation/protocols/cant/research.cant +66 -0
  179. package/src/validation/protocols/cant/specification.cant +67 -0
  180. package/src/validation/protocols/cant/testing.cant +88 -0
  181. package/src/validation/protocols/cant/validation.cant +65 -0
  182. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  183. package/templates/config.template.json +0 -1
  184. package/templates/global-config.template.json +0 -1
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Tests for restore-json-merge.ts (T354).
3
+ *
4
+ * Covers: all 6 classification categories (identical, machine-local,
5
+ * user-intent, project-identity, auto-detect, unknown), nested dot-notation
6
+ * path traversal, missing-on-one-side cases, conflict counting, and the
7
+ * applied merge result correctness per ADR-038 §10 and T311 spec §6.
8
+ *
9
+ * All tests call regenerateAndCompare directly with pre-built A/B objects so
10
+ * there are no filesystem or network side-effects.
11
+ *
12
+ * @task T354
13
+ * @epic T311
14
+ */
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+ import { regenerateAndCompare } from '../restore-json-merge.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Shorthand for a config.json comparison. */
24
+ function compareConfig(local: Record<string, unknown>, imported: Record<string, unknown>) {
25
+ return regenerateAndCompare({ filename: 'config.json', localGenerated: local, imported });
26
+ }
27
+
28
+ /** Shorthand for a project-info.json comparison. */
29
+ function compareInfo(local: Record<string, unknown>, imported: Record<string, unknown>) {
30
+ return regenerateAndCompare({
31
+ filename: 'project-info.json',
32
+ localGenerated: local,
33
+ imported,
34
+ });
35
+ }
36
+
37
+ /** Shorthand for a project-context.json comparison. */
38
+ function compareContext(local: Record<string, unknown>, imported: Record<string, unknown>) {
39
+ return regenerateAndCompare({
40
+ filename: 'project-context.json',
41
+ localGenerated: local,
42
+ imported,
43
+ });
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Identical fields
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe('T354 A/B regenerate-and-compare', () => {
51
+ it('identical fields produce no conflicts', () => {
52
+ const report = compareConfig(
53
+ { brain: { provider: 'local' } },
54
+ { brain: { provider: 'local' } },
55
+ );
56
+ expect(report.conflictCount).toBe(0);
57
+ expect(report.classifications.every((c) => c.category === 'identical')).toBe(true);
58
+ });
59
+
60
+ it('all identical fields have resolution A', () => {
61
+ const report = compareConfig(
62
+ { brain: { provider: 'local' }, hooks: { pre: 'echo hi' } },
63
+ { brain: { provider: 'local' }, hooks: { pre: 'echo hi' } },
64
+ );
65
+ for (const c of report.classifications) {
66
+ expect(c.resolution).toBe('A');
67
+ }
68
+ });
69
+
70
+ // -------------------------------------------------------------------------
71
+ // Machine-local
72
+ // -------------------------------------------------------------------------
73
+
74
+ it('machine-local field (projectRoot) keeps A', () => {
75
+ const report = compareConfig({ projectRoot: '/local/path' }, { projectRoot: '/source/path' });
76
+ const c = report.classifications.find((cl) => cl.path === 'projectRoot');
77
+ expect(c?.category).toBe('machine-local');
78
+ expect(c?.resolution).toBe('A');
79
+ expect((report.applied as Record<string, unknown>).projectRoot).toBe('/local/path');
80
+ });
81
+
82
+ it('machine-local field (hostname) keeps A', () => {
83
+ const report = compareInfo({ hostname: 'my-machine' }, { hostname: 'other-machine' });
84
+ const c = report.classifications.find((cl) => cl.path === 'hostname');
85
+ expect(c?.category).toBe('machine-local');
86
+ expect(c?.resolution).toBe('A');
87
+ expect((report.applied as Record<string, unknown>).hostname).toBe('my-machine');
88
+ });
89
+
90
+ it('machine-local field (createdAt) keeps A', () => {
91
+ const report = compareConfig(
92
+ { createdAt: '2026-01-01T00:00:00Z' },
93
+ { createdAt: '2025-01-01T00:00:00Z' },
94
+ );
95
+ const c = report.classifications.find((cl) => cl.path === 'createdAt');
96
+ expect(c?.category).toBe('machine-local');
97
+ expect(c?.resolution).toBe('A');
98
+ });
99
+
100
+ it('absolute path heuristic classifies Unix path as machine-local', () => {
101
+ const report = compareConfig(
102
+ { arbitraryPath: '/home/user/project' },
103
+ { arbitraryPath: '/home/other/project' },
104
+ );
105
+ const c = report.classifications.find((cl) => cl.path === 'arbitraryPath');
106
+ expect(c?.category).toBe('machine-local');
107
+ expect(c?.resolution).toBe('A');
108
+ });
109
+
110
+ it('absolute path heuristic classifies Windows path as machine-local', () => {
111
+ const report = compareConfig(
112
+ { arbitraryPath: 'C:\\Users\\project' },
113
+ { arbitraryPath: 'D:\\Users\\project' },
114
+ );
115
+ const c = report.classifications.find((cl) => cl.path === 'arbitraryPath');
116
+ expect(c?.category).toBe('machine-local');
117
+ });
118
+
119
+ // -------------------------------------------------------------------------
120
+ // User-intent (config.json)
121
+ // -------------------------------------------------------------------------
122
+
123
+ it('user-intent field (brain.embeddingProvider) keeps B', () => {
124
+ const report = compareConfig(
125
+ { brain: { embeddingProvider: 'local' } },
126
+ { brain: { embeddingProvider: 'openai' } },
127
+ );
128
+ const c = report.classifications.find((cl) => cl.path === 'brain.embeddingProvider');
129
+ expect(c?.category).toBe('user-intent');
130
+ expect(c?.resolution).toBe('B');
131
+ expect(
132
+ (report.applied as Record<string, unknown & { brain: Record<string, unknown> }>).brain
133
+ .embeddingProvider,
134
+ ).toBe('openai');
135
+ });
136
+
137
+ it('user-intent field (enabledFeatures) keeps B', () => {
138
+ const report = compareConfig(
139
+ { enabledFeatures: ['alpha'] },
140
+ { enabledFeatures: ['alpha', 'beta'] },
141
+ );
142
+ const c = report.classifications.find((cl) => cl.path === 'enabledFeatures');
143
+ expect(c?.category).toBe('user-intent');
144
+ expect(c?.resolution).toBe('B');
145
+ expect((report.applied as Record<string, unknown>).enabledFeatures).toEqual(['alpha', 'beta']);
146
+ });
147
+
148
+ it('user-intent field (hooks) keeps B', () => {
149
+ const report = compareConfig(
150
+ { hooks: { pre: 'echo local' } },
151
+ { hooks: { pre: 'echo imported' } },
152
+ );
153
+ const c = report.classifications.find((cl) => cl.path === 'hooks.pre');
154
+ expect(c?.category).toBe('user-intent');
155
+ expect(c?.resolution).toBe('B');
156
+ });
157
+
158
+ it('user-intent does NOT apply to project-info.json', () => {
159
+ // brain.* in project-info.json should fall through to unknown if not
160
+ // matched by any other rule.
161
+ const report = compareInfo({ brain: { x: 1 } }, { brain: { x: 2 } });
162
+ const c = report.classifications.find((cl) => cl.path === 'brain.x');
163
+ expect(c?.category).not.toBe('user-intent');
164
+ });
165
+
166
+ // -------------------------------------------------------------------------
167
+ // Project-identity (project-info.json)
168
+ // -------------------------------------------------------------------------
169
+
170
+ it('project-identity field (name) keeps B', () => {
171
+ const report = compareInfo({ name: 'local-project' }, { name: 'imported-project' });
172
+ const c = report.classifications.find((cl) => cl.path === 'name');
173
+ expect(c?.category).toBe('project-identity');
174
+ expect(c?.resolution).toBe('B');
175
+ expect((report.applied as Record<string, unknown>).name).toBe('imported-project');
176
+ });
177
+
178
+ it('project-identity field (description) keeps B', () => {
179
+ const report = compareInfo(
180
+ { description: 'local description' },
181
+ { description: 'imported description' },
182
+ );
183
+ const c = report.classifications.find((cl) => cl.path === 'description');
184
+ expect(c?.category).toBe('project-identity');
185
+ expect(c?.resolution).toBe('B');
186
+ });
187
+
188
+ it('project-identity field (tags) keeps B', () => {
189
+ const report = compareInfo({ tags: ['ts'] }, { tags: ['ts', 'rust'] });
190
+ const c = report.classifications.find((cl) => cl.path === 'tags');
191
+ expect(c?.category).toBe('project-identity');
192
+ expect(c?.resolution).toBe('B');
193
+ expect((report.applied as Record<string, unknown>).tags).toEqual(['ts', 'rust']);
194
+ });
195
+
196
+ it('project-identity does NOT apply to config.json', () => {
197
+ const report = compareConfig({ name: 'local' }, { name: 'imported' });
198
+ const c = report.classifications.find((cl) => cl.path === 'name');
199
+ expect(c?.category).not.toBe('project-identity');
200
+ });
201
+
202
+ // -------------------------------------------------------------------------
203
+ // Auto-detect (project-context.json)
204
+ // -------------------------------------------------------------------------
205
+
206
+ it('auto-detect field (testing.framework) keeps A', () => {
207
+ const report = compareContext(
208
+ { testing: { framework: 'vitest' } },
209
+ { testing: { framework: 'jest' } },
210
+ );
211
+ const c = report.classifications.find((cl) => cl.path === 'testing.framework');
212
+ expect(c?.category).toBe('auto-detect');
213
+ expect(c?.resolution).toBe('A');
214
+ expect((report.applied as Record<string, Record<string, unknown>>).testing?.framework).toBe(
215
+ 'vitest',
216
+ );
217
+ });
218
+
219
+ it('auto-detect field (build.command) keeps A', () => {
220
+ const report = compareContext(
221
+ { build: { command: 'pnpm build' } },
222
+ { build: { command: 'npm run build' } },
223
+ );
224
+ const c = report.classifications.find((cl) => cl.path === 'build.command');
225
+ expect(c?.category).toBe('auto-detect');
226
+ expect(c?.resolution).toBe('A');
227
+ });
228
+
229
+ it('auto-detect field (llmHints) keeps A', () => {
230
+ const report = compareContext(
231
+ { llmHints: { typeSystem: 'TypeScript strict' } },
232
+ { llmHints: { typeSystem: 'JavaScript' } },
233
+ );
234
+ const c = report.classifications.find((cl) => cl.path === 'llmHints.typeSystem');
235
+ expect(c?.category).toBe('auto-detect');
236
+ expect(c?.resolution).toBe('A');
237
+ });
238
+
239
+ it('auto-detect does NOT apply to config.json', () => {
240
+ const report = compareConfig(
241
+ { testing: { framework: 'vitest' } },
242
+ { testing: { framework: 'jest' } },
243
+ );
244
+ const c = report.classifications.find((cl) => cl.path === 'testing.framework');
245
+ // Should not be auto-detect in config.json — likely unknown or user-intent
246
+ expect(c?.category).not.toBe('auto-detect');
247
+ });
248
+
249
+ // -------------------------------------------------------------------------
250
+ // Unknown / manual-review
251
+ // -------------------------------------------------------------------------
252
+
253
+ it('unknown field is flagged for manual review', () => {
254
+ const report = compareConfig({}, { somethingNew: { weird: 'value' } });
255
+ const c = report.classifications.find((cl) => cl.path.startsWith('somethingNew'));
256
+ expect(c?.category).toBe('unknown');
257
+ expect(c?.resolution).toBe('manual-review');
258
+ expect(report.conflictCount).toBeGreaterThan(0);
259
+ });
260
+
261
+ it('unknown field keeps A (local) as safe default in applied', () => {
262
+ const report = compareConfig({ unknown123: 'local-val' }, { unknown123: 'imported-val' });
263
+ const c = report.classifications.find((cl) => cl.path === 'unknown123');
264
+ expect(c?.category).toBe('unknown');
265
+ expect(c?.resolution).toBe('manual-review');
266
+ // Safe default: keep local value in applied
267
+ expect((report.applied as Record<string, unknown>).unknown123).toBe('local-val');
268
+ });
269
+
270
+ // -------------------------------------------------------------------------
271
+ // Missing-on-one-side
272
+ // -------------------------------------------------------------------------
273
+
274
+ it('field present in B but missing in A', () => {
275
+ const report = compareConfig({ brain: {} }, { brain: { embeddingProvider: 'openai' } });
276
+ const c = report.classifications.find((cl) => cl.path === 'brain.embeddingProvider');
277
+ expect(c?.local).toBeUndefined();
278
+ expect(c?.imported).toBe('openai');
279
+ });
280
+
281
+ it('field present in A but missing in B', () => {
282
+ const report = compareConfig({ brain: { embeddingProvider: 'local' } }, { brain: {} });
283
+ const c = report.classifications.find((cl) => cl.path === 'brain.embeddingProvider');
284
+ expect(c?.local).toBe('local');
285
+ expect(c?.imported).toBeUndefined();
286
+ });
287
+
288
+ it('field absent in A and present in B with user-intent path applies B resolution', () => {
289
+ const report = compareConfig({ brain: {} }, { brain: { newSetting: 'val' } });
290
+ const c = report.classifications.find((cl) => cl.path === 'brain.newSetting');
291
+ expect(c?.category).toBe('user-intent');
292
+ expect(c?.resolution).toBe('B');
293
+ expect((report.applied as Record<string, Record<string, unknown>>).brain?.newSetting).toBe(
294
+ 'val',
295
+ );
296
+ });
297
+
298
+ // -------------------------------------------------------------------------
299
+ // Nested object walking
300
+ // -------------------------------------------------------------------------
301
+
302
+ it('nested object walking produces correct dot-notation paths', () => {
303
+ const report = compareConfig({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } });
304
+ const c = report.classifications.find((cl) => cl.path === 'a.b.c');
305
+ expect(c).toBeDefined();
306
+ });
307
+
308
+ it('deeply nested path is classified correctly', () => {
309
+ const report = compareContext(
310
+ { conventions: { fileNaming: 'kebab-case', importStyle: 'esm' } },
311
+ { conventions: { fileNaming: 'camelCase', importStyle: 'cjs' } },
312
+ );
313
+ const c = report.classifications.find((cl) => cl.path === 'conventions.fileNaming');
314
+ expect(c?.category).toBe('auto-detect');
315
+ expect(c?.resolution).toBe('A');
316
+ });
317
+
318
+ it('arrays are treated as atomic leaf values (no per-element classification)', () => {
319
+ const report = compareInfo({ tags: ['ts', 'node'] }, { tags: ['ts', 'rust'] });
320
+ // Should produce exactly one classification for tags, not one per element
321
+ const tagClassifications = report.classifications.filter((c) => c.path === 'tags');
322
+ expect(tagClassifications).toHaveLength(1);
323
+ });
324
+
325
+ // -------------------------------------------------------------------------
326
+ // Rationale
327
+ // -------------------------------------------------------------------------
328
+
329
+ it('rationale is non-empty for every classification', () => {
330
+ const report = compareConfig({ x: 1, projectRoot: '/a' }, { x: 2, projectRoot: '/b' });
331
+ for (const c of report.classifications) {
332
+ expect(c.rationale.length).toBeGreaterThan(0);
333
+ }
334
+ });
335
+
336
+ it('identical classification rationale mentions identical', () => {
337
+ const report = compareConfig({ brain: { x: 1 } }, { brain: { x: 1 } });
338
+ const c = report.classifications[0];
339
+ expect(c?.rationale).toMatch(/identical/i);
340
+ });
341
+
342
+ // -------------------------------------------------------------------------
343
+ // conflictCount
344
+ // -------------------------------------------------------------------------
345
+
346
+ it('conflictCount = number of manual-review classifications', () => {
347
+ const report = compareConfig({ brain: { x: 1 }, weird: 'a' }, { brain: { x: 2 }, weird: 'b' });
348
+ // brain.x is user-intent (resolution=B, no conflict)
349
+ // weird is unknown (manual-review)
350
+ const manualCount = report.classifications.filter(
351
+ (c) => c.resolution === 'manual-review',
352
+ ).length;
353
+ expect(report.conflictCount).toBe(manualCount);
354
+ });
355
+
356
+ it('conflictCount = 0 when all fields are identical', () => {
357
+ const report = compareConfig({ brain: { x: 1 } }, { brain: { x: 1 } });
358
+ expect(report.conflictCount).toBe(0);
359
+ });
360
+
361
+ it('conflictCount equals unknown-category count exactly', () => {
362
+ const report = compareConfig({ brain: { x: 1 }, weird: 'a' }, { brain: { x: 2 }, weird: 'b' });
363
+ const unknownCount = report.classifications.filter((c) => c.category === 'unknown').length;
364
+ expect(report.conflictCount).toBe(unknownCount);
365
+ });
366
+
367
+ // -------------------------------------------------------------------------
368
+ // Applied merge correctness
369
+ // -------------------------------------------------------------------------
370
+
371
+ it('applied object contains merged values per classification', () => {
372
+ const report = compareConfig(
373
+ { projectRoot: '/local', brain: { provider: 'local' } },
374
+ { projectRoot: '/source', brain: { provider: 'openai' } },
375
+ );
376
+ const applied = report.applied as Record<string, unknown & { brain: Record<string, unknown> }>;
377
+ expect(applied.projectRoot).toBe('/local'); // machine-local → A
378
+ expect(applied.brain.provider).toBe('openai'); // user-intent → B
379
+ });
380
+
381
+ it('multi-field applied: machine-local + project-identity + auto-detect', () => {
382
+ const localInfo = {
383
+ projectRoot: '/local/path',
384
+ name: 'local-project',
385
+ description: 'local desc',
386
+ };
387
+ const importedInfo = {
388
+ projectRoot: '/source/path',
389
+ name: 'imported-project',
390
+ description: 'imported desc',
391
+ };
392
+ const report = regenerateAndCompare({
393
+ filename: 'project-info.json',
394
+ localGenerated: localInfo,
395
+ imported: importedInfo,
396
+ });
397
+ const applied = report.applied as typeof localInfo;
398
+ expect(applied.projectRoot).toBe('/local/path'); // machine-local → A
399
+ expect(applied.name).toBe('imported-project'); // project-identity → B
400
+ expect(applied.description).toBe('imported desc'); // project-identity → B
401
+ });
402
+
403
+ it('auto-detect context: applied keeps A for all auto-detect fields', () => {
404
+ const report = compareContext(
405
+ {
406
+ testing: { framework: 'vitest', command: 'pnpm test' },
407
+ build: { command: 'pnpm build' },
408
+ },
409
+ {
410
+ testing: { framework: 'jest', command: 'npm test' },
411
+ build: { command: 'npm run build' },
412
+ },
413
+ );
414
+ const applied = report.applied as Record<string, Record<string, unknown>>;
415
+ expect(applied.testing?.framework).toBe('vitest');
416
+ expect(applied.testing?.command).toBe('pnpm test');
417
+ expect(applied.build?.command).toBe('pnpm build');
418
+ });
419
+
420
+ // -------------------------------------------------------------------------
421
+ // Report shape
422
+ // -------------------------------------------------------------------------
423
+
424
+ it('report contains filename, localGenerated, imported, classifications, applied', () => {
425
+ const local = { brain: { x: 1 } };
426
+ const imported = { brain: { x: 2 } };
427
+ const report = compareConfig(local, imported);
428
+ expect(report.filename).toBe('config.json');
429
+ expect(report.localGenerated).toEqual(local);
430
+ expect(report.imported).toEqual(imported);
431
+ expect(Array.isArray(report.classifications)).toBe(true);
432
+ expect(report.applied).toBeDefined();
433
+ expect(typeof report.conflictCount).toBe('number');
434
+ });
435
+
436
+ it('report for project-info.json has correct filename', () => {
437
+ const report = compareInfo({ name: 'a' }, { name: 'b' });
438
+ expect(report.filename).toBe('project-info.json');
439
+ });
440
+
441
+ it('report for project-context.json has correct filename', () => {
442
+ const report = compareContext(
443
+ { testing: { framework: 'vitest' } },
444
+ { testing: { framework: 'jest' } },
445
+ );
446
+ expect(report.filename).toBe('project-context.json');
447
+ });
448
+
449
+ // -------------------------------------------------------------------------
450
+ // Spec §8.3 scenarios
451
+ // -------------------------------------------------------------------------
452
+
453
+ it('§8.3: config.json user-intent brain.embeddingProvider — keeps B, in resolved section', () => {
454
+ const report = compareConfig(
455
+ { brain: { embeddingProvider: 'local' } },
456
+ { brain: { embeddingProvider: 'openai' } },
457
+ );
458
+ const c = report.classifications.find((cl) => cl.path === 'brain.embeddingProvider');
459
+ expect(c?.category).toBe('user-intent');
460
+ expect(c?.resolution).toBe('B');
461
+ expect(report.conflictCount).toBe(0);
462
+ });
463
+
464
+ it('§8.3: project-info.json machine-local projectRoot — keeps A', () => {
465
+ const report = compareInfo({ projectRoot: '/local' }, { projectRoot: '/source' });
466
+ const c = report.classifications.find((cl) => cl.path === 'projectRoot');
467
+ expect(c?.category).toBe('machine-local');
468
+ expect(c?.resolution).toBe('A');
469
+ });
470
+
471
+ it('§8.3: project-info.json project-identity name — keeps B', () => {
472
+ const report = compareInfo({ name: 'local' }, { name: 'imported' });
473
+ const c = report.classifications.find((cl) => cl.path === 'name');
474
+ expect(c?.category).toBe('project-identity');
475
+ expect(c?.resolution).toBe('B');
476
+ });
477
+
478
+ it('§8.3: project-context.json auto-detect testing.framework identical — no conflict', () => {
479
+ const report = compareContext(
480
+ { testing: { framework: 'vitest' } },
481
+ { testing: { framework: 'vitest' } },
482
+ );
483
+ const c = report.classifications.find((cl) => cl.path === 'testing.framework');
484
+ expect(c?.category).toBe('identical');
485
+ expect(report.conflictCount).toBe(0);
486
+ });
487
+
488
+ it('§8.3: project-context.json auto-detect build.command differs — keeps A', () => {
489
+ const report = compareContext(
490
+ { build: { command: 'pnpm build' } },
491
+ { build: { command: 'npm run build' } },
492
+ );
493
+ const c = report.classifications.find((cl) => cl.path === 'build.command');
494
+ expect(c?.category).toBe('auto-detect');
495
+ expect(c?.resolution).toBe('A');
496
+ expect((report.applied as Record<string, Record<string, unknown>>).build?.command).toBe(
497
+ 'pnpm build',
498
+ );
499
+ });
500
+
501
+ it('§8.3: config.json unknown field present only in B — manual-review, A used as default', () => {
502
+ const report = compareConfig({}, { strangeThing: 'value' });
503
+ const c = report.classifications.find((cl) => cl.path === 'strangeThing');
504
+ expect(c?.category).toBe('unknown');
505
+ expect(c?.resolution).toBe('manual-review');
506
+ // applied should keep A (undefined → absent) as safe default
507
+ expect((report.applied as Record<string, unknown>).strangeThing).toBeUndefined();
508
+ });
509
+
510
+ it('§8.3: all fields identical across all three files — zero conflicts', () => {
511
+ const configReport = compareConfig({ brain: { x: 1 } }, { brain: { x: 1 } });
512
+ const infoReport = compareInfo({ name: 'proj' }, { name: 'proj' });
513
+ const ctxReport = compareContext(
514
+ { testing: { framework: 'vitest' } },
515
+ { testing: { framework: 'vitest' } },
516
+ );
517
+ expect(configReport.conflictCount).toBe(0);
518
+ expect(infoReport.conflictCount).toBe(0);
519
+ expect(ctxReport.conflictCount).toBe(0);
520
+ });
521
+ });