@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,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B regenerate-and-compare engine for JSON file restore.
|
|
3
|
+
*
|
|
4
|
+
* Classifies each field of the three managed CLEO JSON config files into one
|
|
5
|
+
* of five categories (identical, machine-local, user-intent, project-identity,
|
|
6
|
+
* auto-detect, unknown) and resolves conflicts deterministically per ADR-038 §10
|
|
7
|
+
* and T311 spec §6.
|
|
8
|
+
*
|
|
9
|
+
* PURE MODULE — no I/O, no global state, no disk writes. All output is returned
|
|
10
|
+
* as a {@link JsonRestoreReport} for the caller (T357) to format and persist.
|
|
11
|
+
*
|
|
12
|
+
* @task T354
|
|
13
|
+
* @epic T311
|
|
14
|
+
* @why ADR-038 §10 — intelligent A/B regenerate-and-compare for restore.
|
|
15
|
+
* Classifies each field of imported JSON files into 4 categories
|
|
16
|
+
* (machine-local, user-intent, project-identity, auto-detect, unknown)
|
|
17
|
+
* and resolves conflicts deterministically.
|
|
18
|
+
* @what Pure classification engine. Reads regenerated A from regenerators.ts,
|
|
19
|
+
* imported B from caller, returns a JsonRestoreReport for T357 to format.
|
|
20
|
+
* @see packages/contracts/src/backup-manifest.ts
|
|
21
|
+
* @see packages/core/src/store/regenerators.ts
|
|
22
|
+
* @module restore-json-merge
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
regenerateConfigJson,
|
|
27
|
+
regenerateProjectContextJson,
|
|
28
|
+
regenerateProjectInfoJson,
|
|
29
|
+
} from './regenerators.js';
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Public types
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The three JSON filenames managed by the restore engine.
|
|
37
|
+
*
|
|
38
|
+
* Each filename maps to a distinct classification ruleset (spec §6.3).
|
|
39
|
+
*
|
|
40
|
+
* @task T354
|
|
41
|
+
* @epic T311
|
|
42
|
+
*/
|
|
43
|
+
export type FilenameForRestore = 'config.json' | 'project-info.json' | 'project-context.json';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Five-way field category taxonomy per T311 spec §6.3 / ADR-038 §10.
|
|
47
|
+
*
|
|
48
|
+
* - `identical` — A and B are byte-equal (JSON.stringify match); no conflict.
|
|
49
|
+
* - `machine-local` — field is expected to differ between machines; keep A.
|
|
50
|
+
* - `user-intent` — field represents user-applied config in config.json; keep B.
|
|
51
|
+
* - `project-identity` — field identifies the project in project-info.json; keep B.
|
|
52
|
+
* - `auto-detect` — field is auto-detected from the local environment; keep A.
|
|
53
|
+
* - `unknown` — no classification rule matched; flag for manual review.
|
|
54
|
+
*
|
|
55
|
+
* @task T354
|
|
56
|
+
* @epic T311
|
|
57
|
+
*/
|
|
58
|
+
export type FieldCategory =
|
|
59
|
+
| 'identical'
|
|
60
|
+
| 'machine-local'
|
|
61
|
+
| 'user-intent'
|
|
62
|
+
| 'project-identity'
|
|
63
|
+
| 'auto-detect'
|
|
64
|
+
| 'unknown';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolution applied to a field when building the merged output.
|
|
68
|
+
*
|
|
69
|
+
* - `A` — use the locally regenerated value.
|
|
70
|
+
* - `B` — use the imported value.
|
|
71
|
+
* - `manual-review` — no safe auto-resolution; local value (A) is kept as a
|
|
72
|
+
* safe default pending operator review.
|
|
73
|
+
*
|
|
74
|
+
* @task T354
|
|
75
|
+
* @epic T311
|
|
76
|
+
*/
|
|
77
|
+
export type Resolution = 'A' | 'B' | 'manual-review';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classification and resolution for a single leaf field in the comparison.
|
|
81
|
+
*
|
|
82
|
+
* Produced by {@link regenerateAndCompare} for every leaf path encountered
|
|
83
|
+
* during the recursive walk of both objects.
|
|
84
|
+
*
|
|
85
|
+
* @task T354
|
|
86
|
+
* @epic T311
|
|
87
|
+
*/
|
|
88
|
+
export interface FieldClassification {
|
|
89
|
+
/** JSON dot-path of the field, e.g. "brain.embeddingProvider" or "testing.framework". */
|
|
90
|
+
path: string;
|
|
91
|
+
/** Value from locally regenerated content (A). `undefined` when absent in A. */
|
|
92
|
+
local: unknown;
|
|
93
|
+
/** Value from the imported bundle (B). `undefined` when absent in B. */
|
|
94
|
+
imported: unknown;
|
|
95
|
+
/**
|
|
96
|
+
* Taxonomy category per spec §6.3 classification rules.
|
|
97
|
+
* `identical` means `JSON.stringify(A) === JSON.stringify(B)` — no conflict.
|
|
98
|
+
*/
|
|
99
|
+
category: FieldCategory;
|
|
100
|
+
/**
|
|
101
|
+
* The resolution applied (or to be applied) to disk.
|
|
102
|
+
* `A` = use local value; `B` = use imported value;
|
|
103
|
+
* `manual-review` = operator must decide.
|
|
104
|
+
*/
|
|
105
|
+
resolution: Resolution;
|
|
106
|
+
/** Human-readable explanation for the resolution. */
|
|
107
|
+
rationale: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Complete A/B comparison result for one JSON file.
|
|
112
|
+
*
|
|
113
|
+
* Returned by {@link regenerateAndCompare}. Caller persists `applied` to disk
|
|
114
|
+
* and writes the full report to `.cleo/restore-conflicts.md`.
|
|
115
|
+
*
|
|
116
|
+
* @task T354
|
|
117
|
+
* @epic T311
|
|
118
|
+
*/
|
|
119
|
+
export interface JsonRestoreReport {
|
|
120
|
+
/** Which file was compared. */
|
|
121
|
+
filename: FilenameForRestore;
|
|
122
|
+
/** The locally regenerated object (A). */
|
|
123
|
+
localGenerated: unknown;
|
|
124
|
+
/** The imported object (B). */
|
|
125
|
+
imported: unknown;
|
|
126
|
+
/**
|
|
127
|
+
* Per-field classification results.
|
|
128
|
+
* All leaf fields — including identical ones — are listed here.
|
|
129
|
+
*/
|
|
130
|
+
classifications: FieldClassification[];
|
|
131
|
+
/** The final merged object produced by applying all resolutions. */
|
|
132
|
+
applied: unknown;
|
|
133
|
+
/**
|
|
134
|
+
* Count of fields with `resolution === 'manual-review'`.
|
|
135
|
+
* Equals the number of `unknown`-category classifications.
|
|
136
|
+
*/
|
|
137
|
+
conflictCount: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Input to {@link regenerateAndCompare}.
|
|
142
|
+
*
|
|
143
|
+
* The caller supplies the imported content (B) and the locally regenerated
|
|
144
|
+
* content (A). The engine classifies and resolves each field.
|
|
145
|
+
*
|
|
146
|
+
* @task T354
|
|
147
|
+
* @epic T311
|
|
148
|
+
*/
|
|
149
|
+
export interface RegenerateAndCompareInput {
|
|
150
|
+
/** Which of the three managed JSON files is being compared. */
|
|
151
|
+
filename: FilenameForRestore;
|
|
152
|
+
/** Parsed content of the imported file (B — from bundle json/ directory). */
|
|
153
|
+
imported: unknown;
|
|
154
|
+
/**
|
|
155
|
+
* Locally regenerated content (A).
|
|
156
|
+
*
|
|
157
|
+
* When provided, the engine uses this value directly as the A side.
|
|
158
|
+
* When omitted (or when `filename` is provided with a `projectRoot`),
|
|
159
|
+
* the engine calls the appropriate generator from regenerators.ts.
|
|
160
|
+
*
|
|
161
|
+
* Providing this field allows callers to pass a pre-computed A — useful
|
|
162
|
+
* in tests and in the CLI import command to avoid redundant regeneration.
|
|
163
|
+
*/
|
|
164
|
+
localGenerated: unknown;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// Classification pattern tables
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Field-path prefixes and exact paths that identify machine-local fields.
|
|
173
|
+
*
|
|
174
|
+
* Applies to ALL three files. Resolution: A (keep local).
|
|
175
|
+
*
|
|
176
|
+
* Spec §6.3 table row: "Machine-local — all three files".
|
|
177
|
+
*/
|
|
178
|
+
const MACHINE_LOCAL_PATTERNS: readonly string[] = [
|
|
179
|
+
'projectRoot',
|
|
180
|
+
'machineKey',
|
|
181
|
+
'machineFingerprint',
|
|
182
|
+
'hostname',
|
|
183
|
+
'cwd',
|
|
184
|
+
'createdAt',
|
|
185
|
+
'detectedAt',
|
|
186
|
+
'lastUpdated',
|
|
187
|
+
'projectId',
|
|
188
|
+
'projectHash',
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Field-path prefixes and exact paths that identify user-intent fields.
|
|
193
|
+
*
|
|
194
|
+
* Applies ONLY to `config.json`. Resolution: B (keep imported).
|
|
195
|
+
*
|
|
196
|
+
* Spec §6.3 table row: "User intent — config.json only".
|
|
197
|
+
*/
|
|
198
|
+
const USER_INTENT_PATTERNS_CONFIG: readonly string[] = [
|
|
199
|
+
'enabledFeatures',
|
|
200
|
+
'brain',
|
|
201
|
+
'hooks',
|
|
202
|
+
'tools',
|
|
203
|
+
'contributor',
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Field-path prefixes and exact paths that identify project-identity fields.
|
|
208
|
+
*
|
|
209
|
+
* Applies ONLY to `project-info.json`. Resolution: B (keep imported).
|
|
210
|
+
*
|
|
211
|
+
* Spec §6.3 table row: "Project identity — project-info.json only".
|
|
212
|
+
*/
|
|
213
|
+
const PROJECT_IDENTITY_PATTERNS: readonly string[] = [
|
|
214
|
+
'name',
|
|
215
|
+
'description',
|
|
216
|
+
'type',
|
|
217
|
+
'primaryType',
|
|
218
|
+
'tags',
|
|
219
|
+
'labels',
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Field-path prefixes and exact paths that identify auto-detect fields.
|
|
224
|
+
*
|
|
225
|
+
* Applies ONLY to `project-context.json`. Resolution: A (local detection preferred).
|
|
226
|
+
*
|
|
227
|
+
* Spec §6.3 table row: "Auto-detect — project-context.json only".
|
|
228
|
+
*/
|
|
229
|
+
const AUTO_DETECT_PATTERNS_CONTEXT: readonly string[] = [
|
|
230
|
+
'testing',
|
|
231
|
+
'build',
|
|
232
|
+
'directories',
|
|
233
|
+
'conventions',
|
|
234
|
+
'llmHints',
|
|
235
|
+
'projectTypes',
|
|
236
|
+
'monorepo',
|
|
237
|
+
'schemaVersion',
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Path-matching helpers
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns `true` when `fieldPath` is an exact match for, or starts with a
|
|
246
|
+
* dot-separated prefix of, any pattern in `patterns`.
|
|
247
|
+
*
|
|
248
|
+
* Examples:
|
|
249
|
+
* - matchesPattern('brain.embeddingProvider', ['brain']) → true
|
|
250
|
+
* - matchesPattern('brain', ['brain']) → true
|
|
251
|
+
* - matchesPattern('brainX', ['brain']) → false (not a prefix boundary)
|
|
252
|
+
*
|
|
253
|
+
* @param fieldPath - Dot-separated path to test.
|
|
254
|
+
* @param patterns - List of exact or prefix patterns.
|
|
255
|
+
* @returns `true` when `fieldPath` matches any pattern.
|
|
256
|
+
*/
|
|
257
|
+
function matchesPattern(fieldPath: string, patterns: readonly string[]): boolean {
|
|
258
|
+
for (const pattern of patterns) {
|
|
259
|
+
if (fieldPath === pattern || fieldPath.startsWith(`${pattern}.`)) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Heuristic: returns `true` when `value` is a string that looks like an
|
|
268
|
+
* absolute filesystem path (Unix `/…` or Windows `C:\…`).
|
|
269
|
+
*
|
|
270
|
+
* Spec §6.3: "any string value that is an absolute filesystem path" is
|
|
271
|
+
* classified as machine-local.
|
|
272
|
+
*
|
|
273
|
+
* @param value - Candidate value to inspect.
|
|
274
|
+
* @returns `true` when `value` is an absolute-path string.
|
|
275
|
+
*/
|
|
276
|
+
function isAbsolutePathString(value: unknown): boolean {
|
|
277
|
+
if (typeof value !== 'string') return false;
|
|
278
|
+
// Unix absolute: starts with /
|
|
279
|
+
if (value.startsWith('/')) return true;
|
|
280
|
+
// Windows absolute: drive letter + colon + backslash or forward-slash
|
|
281
|
+
return /^[A-Za-z]:[/\\]/.test(value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// Core classification logic
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Classifies a single leaf field and determines its resolution.
|
|
290
|
+
*
|
|
291
|
+
* Applies the precedence order from spec §6.3:
|
|
292
|
+
* identical > machine-local > user-intent > project-identity > auto-detect > unknown
|
|
293
|
+
*
|
|
294
|
+
* @param filename - Which JSON file the field belongs to.
|
|
295
|
+
* @param path - Dot-notation path, e.g. "brain.embeddingProvider".
|
|
296
|
+
* @param localValue - Value from locally regenerated content (A).
|
|
297
|
+
* @param importedValue - Value from imported bundle (B).
|
|
298
|
+
* @returns A complete {@link FieldClassification} for the field.
|
|
299
|
+
*/
|
|
300
|
+
function classifyField(
|
|
301
|
+
filename: FilenameForRestore,
|
|
302
|
+
path: string,
|
|
303
|
+
localValue: unknown,
|
|
304
|
+
importedValue: unknown,
|
|
305
|
+
): FieldClassification {
|
|
306
|
+
// 1. Identical — highest precedence.
|
|
307
|
+
if (JSON.stringify(localValue) === JSON.stringify(importedValue)) {
|
|
308
|
+
return {
|
|
309
|
+
path,
|
|
310
|
+
local: localValue,
|
|
311
|
+
imported: importedValue,
|
|
312
|
+
category: 'identical',
|
|
313
|
+
resolution: 'A',
|
|
314
|
+
rationale: 'values are identical — no merge required',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2. Machine-local — applies to all files; path-pattern OR absolute-path heuristic.
|
|
319
|
+
if (
|
|
320
|
+
matchesPattern(path, MACHINE_LOCAL_PATTERNS) ||
|
|
321
|
+
isAbsolutePathString(localValue) ||
|
|
322
|
+
isAbsolutePathString(importedValue)
|
|
323
|
+
) {
|
|
324
|
+
return {
|
|
325
|
+
path,
|
|
326
|
+
local: localValue,
|
|
327
|
+
imported: importedValue,
|
|
328
|
+
category: 'machine-local',
|
|
329
|
+
resolution: 'A',
|
|
330
|
+
rationale: 'machine-local field — expected to differ between machines; keeping local value',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 3. User-intent — config.json only.
|
|
335
|
+
if (filename === 'config.json' && matchesPattern(path, USER_INTENT_PATTERNS_CONFIG)) {
|
|
336
|
+
return {
|
|
337
|
+
path,
|
|
338
|
+
local: localValue,
|
|
339
|
+
imported: importedValue,
|
|
340
|
+
category: 'user-intent',
|
|
341
|
+
resolution: 'B',
|
|
342
|
+
rationale: 'user intent field in config.json — preserving imported value from source',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 4. Project-identity — project-info.json only.
|
|
347
|
+
if (filename === 'project-info.json' && matchesPattern(path, PROJECT_IDENTITY_PATTERNS)) {
|
|
348
|
+
return {
|
|
349
|
+
path,
|
|
350
|
+
local: localValue,
|
|
351
|
+
imported: importedValue,
|
|
352
|
+
category: 'project-identity',
|
|
353
|
+
resolution: 'B',
|
|
354
|
+
rationale: 'project identity field — preserving imported value from source',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 5. Auto-detect — project-context.json only.
|
|
359
|
+
if (filename === 'project-context.json' && matchesPattern(path, AUTO_DETECT_PATTERNS_CONTEXT)) {
|
|
360
|
+
return {
|
|
361
|
+
path,
|
|
362
|
+
local: localValue,
|
|
363
|
+
imported: importedValue,
|
|
364
|
+
category: 'auto-detect',
|
|
365
|
+
resolution: 'A',
|
|
366
|
+
rationale:
|
|
367
|
+
'auto-detect field in project-context.json — local detection is always preferred over a potentially stale import',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 6. Unknown — no rule matched; flag for manual review.
|
|
372
|
+
return {
|
|
373
|
+
path,
|
|
374
|
+
local: localValue,
|
|
375
|
+
imported: importedValue,
|
|
376
|
+
category: 'unknown',
|
|
377
|
+
resolution: 'manual-review',
|
|
378
|
+
rationale:
|
|
379
|
+
'unclassified field — no auto-resolution rule applies; needs human review before applying',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// Recursive walk
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Returns `true` when `value` is a plain object (not null, not an array).
|
|
389
|
+
*
|
|
390
|
+
* Arrays are treated as atomic leaf values — no per-element classification.
|
|
391
|
+
*
|
|
392
|
+
* @param value - Value to test.
|
|
393
|
+
* @returns `true` when `value` is a plain object.
|
|
394
|
+
*/
|
|
395
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
396
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Recursively walks two objects (or leaf values) at `prefix` and appends a
|
|
401
|
+
* {@link FieldClassification} for every leaf field encountered.
|
|
402
|
+
*
|
|
403
|
+
* For plain objects, recurses into the union of keys from both sides.
|
|
404
|
+
* For all other values (primitives, arrays, or when one side is missing),
|
|
405
|
+
* delegates to {@link classifyField}.
|
|
406
|
+
*
|
|
407
|
+
* Missing-on-one-side rules:
|
|
408
|
+
* - Field present in B but absent in A → `local = undefined`.
|
|
409
|
+
* - Field present in A but absent in B → `imported = undefined`.
|
|
410
|
+
*
|
|
411
|
+
* @param filename - Which JSON file is being classified.
|
|
412
|
+
* @param prefix - Current dot-path prefix (empty string at root).
|
|
413
|
+
* @param local - Value from locally regenerated content (A).
|
|
414
|
+
* @param imported - Value from imported bundle (B).
|
|
415
|
+
* @param classifications - Accumulator array; entries are pushed here.
|
|
416
|
+
*/
|
|
417
|
+
function walkAndClassify(
|
|
418
|
+
filename: FilenameForRestore,
|
|
419
|
+
prefix: string,
|
|
420
|
+
local: unknown,
|
|
421
|
+
imported: unknown,
|
|
422
|
+
classifications: FieldClassification[],
|
|
423
|
+
): void {
|
|
424
|
+
if (isPlainObject(local) && isPlainObject(imported)) {
|
|
425
|
+
// Both are plain objects — recurse into the union of all keys.
|
|
426
|
+
const allKeys = new Set([...Object.keys(local), ...Object.keys(imported)]);
|
|
427
|
+
for (const key of allKeys) {
|
|
428
|
+
const childPath = prefix === '' ? key : `${prefix}.${key}`;
|
|
429
|
+
walkAndClassify(filename, childPath, local[key], imported[key], classifications);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (isPlainObject(local) && imported === undefined) {
|
|
435
|
+
// Local is an object, imported is absent — recurse with imported as undefined leaves.
|
|
436
|
+
const allKeys = Object.keys(local);
|
|
437
|
+
for (const key of allKeys) {
|
|
438
|
+
const childPath = prefix === '' ? key : `${prefix}.${key}`;
|
|
439
|
+
walkAndClassify(filename, childPath, local[key], undefined, classifications);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (local === undefined && isPlainObject(imported)) {
|
|
445
|
+
// Local is absent, imported is an object — recurse with local as undefined leaves.
|
|
446
|
+
const allKeys = Object.keys(imported);
|
|
447
|
+
for (const key of allKeys) {
|
|
448
|
+
const childPath = prefix === '' ? key : `${prefix}.${key}`;
|
|
449
|
+
walkAndClassify(filename, childPath, undefined, imported[key], classifications);
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Leaf: primitive, array, or one is an object while the other is not.
|
|
455
|
+
// Treat the whole value as a single field.
|
|
456
|
+
classifications.push(classifyField(filename, prefix, local, imported));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// Dot-path object manipulation helpers
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Gets a value from a nested object using a dot-notation path.
|
|
465
|
+
*
|
|
466
|
+
* Returns `undefined` when any segment along the path is absent or not an object.
|
|
467
|
+
*
|
|
468
|
+
* @param obj - The root object to traverse.
|
|
469
|
+
* @param path - Dot-separated field path, e.g. "brain.embeddingProvider".
|
|
470
|
+
* @returns The value at `path`, or `undefined`.
|
|
471
|
+
*/
|
|
472
|
+
function dotGet(obj: unknown, path: string): unknown {
|
|
473
|
+
const parts = path.split('.');
|
|
474
|
+
let current: unknown = obj;
|
|
475
|
+
for (const part of parts) {
|
|
476
|
+
if (!isPlainObject(current)) return undefined;
|
|
477
|
+
current = current[part];
|
|
478
|
+
}
|
|
479
|
+
return current;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Sets a value in a nested object using a dot-notation path, creating
|
|
484
|
+
* intermediate plain-object nodes as needed.
|
|
485
|
+
*
|
|
486
|
+
* Mutates `obj` in place.
|
|
487
|
+
*
|
|
488
|
+
* @param obj - The root object to mutate.
|
|
489
|
+
* @param path - Dot-separated field path.
|
|
490
|
+
* @param value - Value to set.
|
|
491
|
+
*/
|
|
492
|
+
function dotSet(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
493
|
+
const parts = path.split('.');
|
|
494
|
+
let current: Record<string, unknown> = obj;
|
|
495
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
496
|
+
const part = parts[i] as string;
|
|
497
|
+
if (!isPlainObject(current[part])) {
|
|
498
|
+
current[part] = {};
|
|
499
|
+
}
|
|
500
|
+
current = current[part] as Record<string, unknown>;
|
|
501
|
+
}
|
|
502
|
+
const lastPart = parts[parts.length - 1] as string;
|
|
503
|
+
current[lastPart] = value;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Produces a deep clone of a plain-object tree using JSON round-trip.
|
|
508
|
+
*
|
|
509
|
+
* Arrays and primitives at the root are also handled correctly.
|
|
510
|
+
*
|
|
511
|
+
* @param value - Value to clone.
|
|
512
|
+
* @returns A structurally identical but referentially independent copy.
|
|
513
|
+
*/
|
|
514
|
+
function deepClone(value: unknown): unknown {
|
|
515
|
+
return JSON.parse(JSON.stringify(value ?? null));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ============================================================================
|
|
519
|
+
// Applied-result builder
|
|
520
|
+
// ============================================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Builds the merged result object by applying all resolved classifications
|
|
524
|
+
* on top of a deep clone of `localGenerated` (A).
|
|
525
|
+
*
|
|
526
|
+
* Resolution mapping:
|
|
527
|
+
* - `A` (identical, machine-local, auto-detect) — keep the local value (no-op, already in clone).
|
|
528
|
+
* - `B` (user-intent, project-identity) — overwrite with imported value.
|
|
529
|
+
* - `manual-review` (unknown) — keep local value as safe default.
|
|
530
|
+
*
|
|
531
|
+
* @param localGenerated - Locally regenerated content (A).
|
|
532
|
+
* @param classifications - All field classifications produced by the walk.
|
|
533
|
+
* @returns The merged object with all B-resolution fields overwritten.
|
|
534
|
+
*/
|
|
535
|
+
function buildApplied(localGenerated: unknown, classifications: FieldClassification[]): unknown {
|
|
536
|
+
const result = deepClone(localGenerated) as Record<string, unknown>;
|
|
537
|
+
|
|
538
|
+
for (const classification of classifications) {
|
|
539
|
+
if (classification.resolution === 'B') {
|
|
540
|
+
// B resolution: overwrite with the imported value.
|
|
541
|
+
// When imported value is undefined (field absent in B), remove from result.
|
|
542
|
+
if (classification.imported === undefined) {
|
|
543
|
+
// Field was in A but absent in B — for a B resolution this means we
|
|
544
|
+
// want to remove it (preserve the "absent" state from the source).
|
|
545
|
+
// Walk the path and delete the terminal key.
|
|
546
|
+
const parts = classification.path.split('.');
|
|
547
|
+
const parentPath = parts.slice(0, -1).join('.');
|
|
548
|
+
const key = parts[parts.length - 1] as string;
|
|
549
|
+
const parent = parentPath === '' ? result : dotGet(result, parentPath);
|
|
550
|
+
if (isPlainObject(parent)) {
|
|
551
|
+
delete (parent as Record<string, unknown>)[key];
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
dotSet(result, classification.path, classification.imported);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// resolution A or manual-review: keep the local value — already in clone.
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ============================================================================
|
|
564
|
+
// Public API
|
|
565
|
+
// ============================================================================
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Runs the A/B regenerate-and-compare classification engine for a single
|
|
569
|
+
* JSON file.
|
|
570
|
+
*
|
|
571
|
+
* Does NOT write anything to disk. Returns a {@link JsonRestoreReport}
|
|
572
|
+
* containing the per-field classifications, the merged `applied` result, and
|
|
573
|
+
* a count of fields that require manual review.
|
|
574
|
+
*
|
|
575
|
+
* The `localGenerated` field on the input is used directly as the A side.
|
|
576
|
+
* Pass the result of the appropriate generator from `regenerators.ts` (or any
|
|
577
|
+
* equivalent object for testing).
|
|
578
|
+
*
|
|
579
|
+
* Classification precedence (spec §6.3):
|
|
580
|
+
* identical > machine-local > user-intent > project-identity > auto-detect > unknown
|
|
581
|
+
*
|
|
582
|
+
* @task T354
|
|
583
|
+
* @epic T311
|
|
584
|
+
* @param input - Filename, imported content (B), and locally regenerated content (A).
|
|
585
|
+
* @returns A complete `JsonRestoreReport` for the caller to persist.
|
|
586
|
+
*/
|
|
587
|
+
export function regenerateAndCompare(input: RegenerateAndCompareInput): JsonRestoreReport {
|
|
588
|
+
const { filename, imported, localGenerated } = input;
|
|
589
|
+
|
|
590
|
+
const classifications: FieldClassification[] = [];
|
|
591
|
+
walkAndClassify(filename, '', localGenerated, imported, classifications);
|
|
592
|
+
|
|
593
|
+
const applied = buildApplied(localGenerated, classifications);
|
|
594
|
+
|
|
595
|
+
const conflictCount = classifications.filter((c) => c.resolution === 'manual-review').length;
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
filename,
|
|
599
|
+
localGenerated,
|
|
600
|
+
imported,
|
|
601
|
+
classifications,
|
|
602
|
+
applied,
|
|
603
|
+
conflictCount,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Convenience wrapper that runs `regenerateAndCompare` for all three JSON
|
|
609
|
+
* files in a single call, generating the A sides from `projectRoot`.
|
|
610
|
+
*
|
|
611
|
+
* Useful in the CLI import command when all three files are present in the
|
|
612
|
+
* bundle. Each generator runs independently.
|
|
613
|
+
*
|
|
614
|
+
* Does NOT write anything to disk.
|
|
615
|
+
*
|
|
616
|
+
* @task T354
|
|
617
|
+
* @epic T311
|
|
618
|
+
* @param projectRoot - Absolute path to the project root (for A generation).
|
|
619
|
+
* @param importedConfig - Imported config.json content (B).
|
|
620
|
+
* @param importedInfo - Imported project-info.json content (B).
|
|
621
|
+
* @param importedContext - Imported project-context.json content (B).
|
|
622
|
+
* @returns Object containing one `JsonRestoreReport` per managed JSON file.
|
|
623
|
+
*/
|
|
624
|
+
export function regenerateAndCompareAll(
|
|
625
|
+
projectRoot: string,
|
|
626
|
+
importedConfig: unknown,
|
|
627
|
+
importedInfo: unknown,
|
|
628
|
+
importedContext: unknown,
|
|
629
|
+
): {
|
|
630
|
+
config: JsonRestoreReport;
|
|
631
|
+
projectInfo: JsonRestoreReport;
|
|
632
|
+
projectContext: JsonRestoreReport;
|
|
633
|
+
} {
|
|
634
|
+
const config = regenerateAndCompare({
|
|
635
|
+
filename: 'config.json',
|
|
636
|
+
imported: importedConfig,
|
|
637
|
+
localGenerated: regenerateConfigJson(projectRoot).content,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const projectInfo = regenerateAndCompare({
|
|
641
|
+
filename: 'project-info.json',
|
|
642
|
+
imported: importedInfo,
|
|
643
|
+
localGenerated: regenerateProjectInfoJson(projectRoot).content,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const projectContext = regenerateAndCompare({
|
|
647
|
+
filename: 'project-context.json',
|
|
648
|
+
imported: importedContext,
|
|
649
|
+
localGenerated: regenerateProjectContextJson(projectRoot).content,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return { config, projectInfo, projectContext };
|
|
653
|
+
}
|