@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.
- package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
- package/dist/codebase-map/analyzers/architecture.js +0 -1
- package/dist/codebase-map/analyzers/architecture.js.map +1 -1
- package/dist/conduit/local-transport.d.ts +18 -8
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +23 -13
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.js +175 -68950
- package/dist/index.js.map +1 -7
- package/dist/init.d.ts +1 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +8 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -6
- package/dist/internal.js.map +1 -1
- package/dist/memory/learnings.d.ts +2 -2
- package/dist/memory/patterns.d.ts +6 -6
- package/dist/output.d.ts +32 -11
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +67 -67
- package/dist/output.js.map +1 -1
- package/dist/paths.js +80 -14
- package/dist/paths.js.map +1 -1
- package/dist/skills/dynamic-skill-generator.d.ts +0 -2
- package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
- package/dist/skills/dynamic-skill-generator.js.map +1 -1
- package/dist/store/agent-registry-accessor.d.ts +203 -12
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +618 -100
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +73 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -0
- package/dist/store/api-key-kdf.js +84 -0
- package/dist/store/api-key-kdf.js.map +1 -0
- package/dist/store/cleanup-legacy.js +171 -0
- package/dist/store/cleanup-legacy.js.map +1 -0
- package/dist/store/conduit-sqlite.d.ts +184 -0
- package/dist/store/conduit-sqlite.d.ts.map +1 -0
- package/dist/store/conduit-sqlite.js +570 -0
- package/dist/store/conduit-sqlite.js.map +1 -0
- package/dist/store/global-salt.d.ts +78 -0
- package/dist/store/global-salt.d.ts.map +1 -0
- package/dist/store/global-salt.js +147 -0
- package/dist/store/global-salt.js.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.js +555 -0
- package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
- package/dist/store/nexus-sqlite.js +28 -3
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/signaldock-sqlite.d.ts +122 -19
- package/dist/store/signaldock-sqlite.d.ts.map +1 -1
- package/dist/store/signaldock-sqlite.js +401 -251
- package/dist/store/signaldock-sqlite.js.map +1 -1
- package/dist/store/sqlite-backup.js +122 -4
- package/dist/store/sqlite-backup.js.map +1 -1
- package/dist/system/backup.d.ts +0 -26
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/runtime.d.ts +0 -2
- package/dist/system/runtime.d.ts.map +1 -1
- package/dist/system/runtime.js +3 -3
- package/dist/system/runtime.js.map +1 -1
- package/dist/tasks/add.d.ts +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +98 -23
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +4 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +4 -1
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/labels.d.ts.map +1 -1
- package/dist/tasks/labels.js +4 -1
- package/dist/tasks/labels.js.map +1 -1
- package/dist/tasks/relates.d.ts.map +1 -1
- package/dist/tasks/relates.js +16 -4
- package/dist/tasks/relates.js.map +1 -1
- package/dist/tasks/show.d.ts.map +1 -1
- package/dist/tasks/show.js +4 -1
- package/dist/tasks/show.js.map +1 -1
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +32 -6
- package/dist/tasks/update.js.map +1 -1
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/engine.js +16 -4
- package/dist/validation/engine.js.map +1 -1
- package/dist/validation/param-utils.d.ts +5 -3
- package/dist/validation/param-utils.d.ts.map +1 -1
- package/dist/validation/param-utils.js +8 -6
- package/dist/validation/param-utils.js.map +1 -1
- package/dist/validation/protocols/_shared.d.ts.map +1 -1
- package/dist/validation/protocols/_shared.js +13 -6
- package/dist/validation/protocols/_shared.js.map +1 -1
- package/package.json +9 -7
- package/src/adapters/__tests__/manager.test.ts +0 -1
- package/src/codebase-map/analyzers/architecture.ts +0 -1
- package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
- package/src/conduit/__tests__/local-transport.test.ts +14 -12
- package/src/conduit/local-transport.ts +23 -13
- package/src/config.ts +0 -1
- package/src/errors.ts +24 -0
- package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
- package/src/init.ts +1 -2
- package/src/internal.ts +96 -2
- package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
- package/src/memory/__tests__/engine-compat.test.ts +2 -2
- package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
- package/src/observability/__tests__/index.test.ts +4 -4
- package/src/observability/__tests__/log-filter.test.ts +4 -4
- package/src/output.ts +73 -75
- package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
- package/src/sessions/__tests__/session-grade.test.ts +2 -2
- package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
- package/src/skills/dynamic-skill-generator.ts +0 -2
- package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
- package/src/store/__tests__/api-key-kdf.test.ts +113 -0
- package/src/store/__tests__/backup-crypto.test.ts +101 -0
- package/src/store/__tests__/backup-pack.test.ts +491 -0
- package/src/store/__tests__/backup-unpack.test.ts +298 -0
- package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
- package/src/store/__tests__/global-salt.test.ts +195 -0
- package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
- package/src/store/__tests__/regenerators.test.ts +234 -0
- package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
- package/src/store/__tests__/restore-json-merge.test.ts +521 -0
- package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
- package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
- package/src/store/__tests__/sqlite-backup.test.ts +5 -1
- package/src/store/__tests__/t310-integration.test.ts +1150 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/agent-registry-accessor.ts +847 -140
- package/src/store/api-key-kdf.ts +104 -0
- package/src/store/backup-crypto.ts +209 -0
- package/src/store/backup-pack.ts +739 -0
- package/src/store/backup-unpack.ts +583 -0
- package/src/store/conduit-sqlite.ts +655 -0
- package/src/store/global-salt.ts +175 -0
- package/src/store/migrate-signaldock-to-conduit.ts +669 -0
- package/src/store/regenerators.ts +243 -0
- package/src/store/restore-conflict-report.ts +317 -0
- package/src/store/restore-json-merge.ts +653 -0
- package/src/store/signaldock-sqlite.ts +431 -254
- package/src/store/sqlite-backup.ts +185 -10
- package/src/store/t310-readiness.ts +119 -0
- package/src/system/backup.ts +2 -62
- package/src/system/runtime.ts +4 -6
- package/src/tasks/__tests__/error-hints.test.ts +256 -0
- package/src/tasks/add.ts +99 -9
- package/src/tasks/complete.ts +4 -1
- package/src/tasks/find.ts +4 -1
- package/src/tasks/labels.ts +4 -1
- package/src/tasks/relates.ts +16 -4
- package/src/tasks/show.ts +4 -1
- package/src/tasks/update.ts +32 -3
- package/src/validation/__tests__/error-hints.test.ts +97 -0
- package/src/validation/engine.ts +16 -1
- package/src/validation/param-utils.ts +10 -7
- package/src/validation/protocols/_shared.ts +14 -6
- package/src/validation/protocols/cant/architecture-decision.cant +80 -0
- package/src/validation/protocols/cant/artifact-publish.cant +95 -0
- package/src/validation/protocols/cant/consensus.cant +74 -0
- package/src/validation/protocols/cant/contribution.cant +82 -0
- package/src/validation/protocols/cant/decomposition.cant +92 -0
- package/src/validation/protocols/cant/implementation.cant +67 -0
- package/src/validation/protocols/cant/provenance.cant +88 -0
- package/src/validation/protocols/cant/release.cant +96 -0
- package/src/validation/protocols/cant/research.cant +66 -0
- package/src/validation/protocols/cant/specification.cant +67 -0
- package/src/validation/protocols/cant/testing.cant +88 -0
- package/src/validation/protocols/cant/validation.cant +65 -0
- package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
- package/templates/config.template.json +0 -1
- 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
|
+
});
|