@softspark/ai-toolkit 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env python3
2
+ """Config merger for ai-toolkit extends system.
3
+
4
+ Implements layered deep merge with:
5
+ - Constitution immutability (Articles I-V absolute, base articles immutable)
6
+ - Agent merge with requiredAgents enforcement
7
+ - Override validation (override:true + justification required)
8
+ - enforce block constraints (minHookProfile, requiredPlugins, forbidOverride, requiredAgents)
9
+
10
+ Stdlib-only — no external dependencies.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Constants
23
+ # ---------------------------------------------------------------------------
24
+
25
+ IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5})
26
+
27
+ HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
28
+
29
+ # v1 schema fields that participate in merge
30
+ V1_FIELDS = frozenset({
31
+ "$schema", "extends", "name", "version", "description",
32
+ "profile", "agents", "rules", "constitution", "enforce", "overrides",
33
+ })
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Exceptions
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class ConfigMergeError(Exception):
41
+ """Raised when config merge fails due to constraint violation."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Data classes
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @dataclass
49
+ class MergeResult:
50
+ """Result of merging configs."""
51
+
52
+ merged: dict[str, Any]
53
+ warnings: list[str] = field(default_factory=list)
54
+ overrides_applied: list[dict[str, str]] = field(default_factory=list)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Public API
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def merge_config_chain(
62
+ base_configs: list[dict[str, Any]],
63
+ project_config: dict[str, Any],
64
+ ) -> MergeResult:
65
+ """Merge an ordered chain of base configs with a project config.
66
+
67
+ Args:
68
+ base_configs: Ordered list of base config dicts (deepest ancestor first).
69
+ project_config: The project-level .ai-toolkit.json data.
70
+
71
+ Returns:
72
+ MergeResult with the final merged config.
73
+
74
+ Raises:
75
+ ConfigMergeError: On constraint violations.
76
+ """
77
+ result = MergeResult(merged={})
78
+
79
+ # Layer base configs (deepest ancestor → most immediate parent)
80
+ accumulated_base: dict[str, Any] = {}
81
+ for base in base_configs:
82
+ accumulated_base = _deep_merge(accumulated_base, base)
83
+
84
+ # Merge project over accumulated base
85
+ result.merged = _merge_project_over_base(accumulated_base, project_config, result)
86
+
87
+ # Validate enforce constraints
88
+ _validate_enforce(accumulated_base, result.merged)
89
+
90
+ return result
91
+
92
+
93
+ def merge_two(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
94
+ """Simple two-config merge (base → overlay). No enforcement validation."""
95
+ return _deep_merge(base, overlay)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Internal: project-over-base merge (with enforcement)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def _merge_project_over_base(
103
+ base: dict[str, Any],
104
+ project: dict[str, Any],
105
+ result: MergeResult,
106
+ ) -> dict[str, Any]:
107
+ """Merge project config over base with special handling for key sections."""
108
+ merged: dict[str, Any] = {}
109
+
110
+ all_keys = set(base.keys()) | set(project.keys())
111
+
112
+ for key in all_keys:
113
+ # Skip meta fields that don't participate in merge output
114
+ if key in ("$schema", "extends", "name", "version", "description"):
115
+ # Project values win for metadata
116
+ merged[key] = project.get(key) or base.get(key)
117
+ continue
118
+
119
+ base_val = base.get(key)
120
+ proj_val = project.get(key)
121
+
122
+ if proj_val is None:
123
+ merged[key] = base_val
124
+ elif key == "overrides":
125
+ # Always validate overrides against base enforce, even if base has no overrides
126
+ merged[key] = _validate_overrides(base, proj_val, result)
127
+ elif key == "constitution" and base_val is not None:
128
+ merged[key] = _merge_constitution(base_val, proj_val, base)
129
+ elif base_val is None:
130
+ merged[key] = proj_val
131
+ elif key == "constitution":
132
+ merged[key] = _merge_constitution(base_val, proj_val, base)
133
+ elif key == "agents":
134
+ merged[key] = _merge_agents(base_val, proj_val, base)
135
+ elif key == "rules":
136
+ merged[key] = _merge_rules(base_val, proj_val)
137
+ elif key == "overrides":
138
+ merged[key] = _validate_overrides(base, proj_val, result)
139
+ elif key == "enforce":
140
+ # enforce blocks merge: base wins (projects cannot weaken enforcement)
141
+ merged[key] = _merge_enforce(base_val, proj_val)
142
+ elif key == "profile":
143
+ merged[key] = proj_val # project can change profile
144
+ elif isinstance(base_val, dict) and isinstance(proj_val, dict):
145
+ merged[key] = _deep_merge(base_val, proj_val)
146
+ elif isinstance(base_val, list) and isinstance(proj_val, list):
147
+ merged[key] = _merge_lists(base_val, proj_val)
148
+ else:
149
+ merged[key] = proj_val # scalar: project wins
150
+
151
+ return merged
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Merge: constitution (immutability guard)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _merge_constitution(
159
+ base: dict[str, Any],
160
+ project: dict[str, Any],
161
+ full_base: dict[str, Any],
162
+ ) -> dict[str, Any]:
163
+ """Merge constitution — additions only, no modifications.
164
+
165
+ Rules:
166
+ 1. Articles I-V (1-5) are ABSOLUTELY immutable — toolkit core.
167
+ 2. Articles defined by base configs are immutable — projects cannot modify.
168
+ 3. Projects can ADD new articles with article numbers not in base.
169
+ """
170
+ base_amendments = {a["article"]: a for a in base.get("amendments", [])}
171
+ proj_amendments = {a["article"]: a for a in project.get("amendments", [])}
172
+
173
+ merged = dict(base_amendments)
174
+ base_name = full_base.get("name", "base config")
175
+
176
+ for article_num, amendment in proj_amendments.items():
177
+ if article_num in IMMUTABLE_ARTICLES:
178
+ raise ConfigMergeError(
179
+ f"Cannot modify Constitution Article {article_num} — immutable.\n"
180
+ f"Articles I-V are defined by ai-toolkit and cannot be overridden.\n"
181
+ f"You can ADD new articles (article 6+)."
182
+ )
183
+ if article_num in base_amendments:
184
+ raise ConfigMergeError(
185
+ f"Cannot modify Constitution Article {article_num} — "
186
+ f"defined by base config '{base_name}'.\n"
187
+ f"Base articles are immutable. You can ADD new articles "
188
+ f"with a higher article number."
189
+ )
190
+ merged[article_num] = amendment
191
+
192
+ return {"amendments": sorted(merged.values(), key=lambda a: a["article"])}
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Merge: agents
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def _merge_agents(
200
+ base: dict[str, Any],
201
+ project: dict[str, Any],
202
+ full_base: dict[str, Any],
203
+ ) -> dict[str, Any]:
204
+ """Merge agent configs — project can enable/disable but not remove base-required."""
205
+ merged_enabled = set(base.get("enabled", []))
206
+ required_agents = set(full_base.get("enforce", {}).get("requiredAgents", []))
207
+
208
+ # Project can add agents
209
+ merged_enabled.update(project.get("enabled", []))
210
+
211
+ # Project can disable agents (unless base enforces them)
212
+ for agent in project.get("disabled", []):
213
+ if agent in required_agents:
214
+ raise ConfigMergeError(
215
+ f"Cannot disable agent '{agent}' — required by base config "
216
+ f"'{full_base.get('name', 'unknown')}'.\n"
217
+ f"Required agents: {', '.join(sorted(required_agents))}\n"
218
+ f"Contact your team lead to request an exemption."
219
+ )
220
+ merged_enabled.discard(agent)
221
+
222
+ return {
223
+ "enabled": sorted(merged_enabled),
224
+ "disabled": sorted(set(project.get("disabled", [])) - required_agents),
225
+ "custom": base.get("custom", []) + project.get("custom", []),
226
+ }
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Merge: rules
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def _merge_rules(
234
+ base: dict[str, Any],
235
+ project: dict[str, Any],
236
+ ) -> dict[str, Any]:
237
+ """Merge rules — union inject lists, apply removals."""
238
+ base_inject = set(base.get("inject", []))
239
+ proj_inject = set(project.get("inject", []))
240
+ proj_remove = set(project.get("remove", []))
241
+
242
+ merged_inject = (base_inject | proj_inject) - proj_remove
243
+
244
+ return {
245
+ "inject": sorted(merged_inject),
246
+ "remove": sorted(proj_remove),
247
+ }
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Merge: enforce
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def _merge_enforce(
255
+ base: dict[str, Any],
256
+ project: dict[str, Any],
257
+ ) -> dict[str, Any]:
258
+ """Merge enforce blocks — base constraints cannot be weakened, only strengthened."""
259
+ merged: dict[str, Any] = dict(base)
260
+
261
+ # minHookProfile: take the stricter one
262
+ base_profile = base.get("minHookProfile", "minimal")
263
+ proj_profile = project.get("minHookProfile", "minimal")
264
+ base_level = HOOK_PROFILE_ORDER.get(base_profile, 0)
265
+ proj_level = HOOK_PROFILE_ORDER.get(proj_profile, 0)
266
+ merged["minHookProfile"] = proj_profile if proj_level >= base_level else base_profile
267
+
268
+ # Lists: union (project can add, not remove)
269
+ for list_key in ("requiredPlugins", "forbidOverride", "requiredAgents"):
270
+ base_list = set(base.get(list_key, []))
271
+ proj_list = set(project.get(list_key, []))
272
+ merged[list_key] = sorted(base_list | proj_list)
273
+
274
+ return merged
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Override validation
279
+ # ---------------------------------------------------------------------------
280
+
281
+ def _validate_overrides(
282
+ base: dict[str, Any],
283
+ overrides: dict[str, Any],
284
+ result: MergeResult,
285
+ ) -> dict[str, Any]:
286
+ """Validate project overrides against base enforcement rules."""
287
+ forbidden = set(base.get("enforce", {}).get("forbidOverride", []))
288
+
289
+ validated: dict[str, Any] = {}
290
+
291
+ for key, override in overrides.items():
292
+ # Check if component is a nested dict with override/justification
293
+ if not isinstance(override, dict):
294
+ raise ConfigMergeError(
295
+ f"Override for '{key}' must be an object with 'override' and 'justification' fields."
296
+ )
297
+
298
+ if key in forbidden:
299
+ raise ConfigMergeError(
300
+ f"Cannot override '{key}' — forbidden by base config "
301
+ f"'{base.get('name', 'unknown')}'.\n"
302
+ f"Forbidden overrides: {', '.join(sorted(forbidden))}\n"
303
+ f"Contact your team lead to request an exemption."
304
+ )
305
+
306
+ if not override.get("override"):
307
+ raise ConfigMergeError(
308
+ f"Override for '{key}' requires explicit 'override: true'.\n"
309
+ f"This ensures intentional deviation from organizational defaults."
310
+ )
311
+
312
+ justification = override.get("justification", "")
313
+ if not justification or len(justification) < 20:
314
+ raise ConfigMergeError(
315
+ f"Override for '{key}' requires a 'justification' field (min 20 chars).\n"
316
+ f"Got: '{justification}' ({len(justification)} chars)\n"
317
+ f"Example: \"Company uses custom lint pipeline via Jenkins\""
318
+ )
319
+
320
+ validated[key] = override
321
+ result.overrides_applied.append({
322
+ "key": key,
323
+ "action": override.get("replacement", "custom"),
324
+ "justification": justification,
325
+ })
326
+
327
+ return validated
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # Enforce validation (post-merge)
332
+ # ---------------------------------------------------------------------------
333
+
334
+ def _validate_enforce(
335
+ base: dict[str, Any],
336
+ merged: dict[str, Any],
337
+ ) -> None:
338
+ """Validate that the merged config satisfies base enforce constraints."""
339
+ enforce = base.get("enforce", {})
340
+ if not enforce:
341
+ return
342
+
343
+ errors: list[str] = []
344
+
345
+ # minHookProfile
346
+ min_profile = enforce.get("minHookProfile")
347
+ if min_profile:
348
+ merged_profile = merged.get("profile", "standard")
349
+ # Map profiles to hook profile levels
350
+ profile_to_hook = {
351
+ "minimal": "minimal",
352
+ "standard": "standard",
353
+ "strict": "strict",
354
+ "full": "strict",
355
+ "offline-slm": "minimal",
356
+ }
357
+ merged_hook = profile_to_hook.get(merged_profile, "standard")
358
+ min_level = HOOK_PROFILE_ORDER.get(min_profile, 0)
359
+ merged_level = HOOK_PROFILE_ORDER.get(merged_hook, 1)
360
+ if merged_level < min_level:
361
+ errors.append(
362
+ f"Profile '{merged_profile}' (hook level: {merged_hook}) "
363
+ f"is below minimum '{min_profile}' required by base config."
364
+ )
365
+
366
+ # requiredPlugins — deferred to v2 (plugins field not in v1)
367
+
368
+ # requiredAgents
369
+ required_agents = set(enforce.get("requiredAgents", []))
370
+ if required_agents:
371
+ enabled_agents = set(merged.get("agents", {}).get("enabled", []))
372
+ missing = required_agents - enabled_agents
373
+ if missing:
374
+ errors.append(
375
+ f"Required agents missing: {', '.join(sorted(missing))}.\n"
376
+ f"These agents are required by the base config and cannot be disabled."
377
+ )
378
+
379
+ if errors:
380
+ raise ConfigMergeError(
381
+ "Enforce constraint violations:\n" + "\n".join(f" - {e}" for e in errors)
382
+ )
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Generic merge helpers
387
+ # ---------------------------------------------------------------------------
388
+
389
+ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
390
+ """Generic deep merge: overlay wins for scalars, recurse for dicts, union for lists."""
391
+ merged: dict[str, Any] = {}
392
+
393
+ for key in set(base.keys()) | set(overlay.keys()):
394
+ base_val = base.get(key)
395
+ over_val = overlay.get(key)
396
+
397
+ if over_val is None:
398
+ merged[key] = base_val
399
+ elif base_val is None:
400
+ merged[key] = over_val
401
+ elif isinstance(base_val, dict) and isinstance(over_val, dict):
402
+ merged[key] = _deep_merge(base_val, over_val)
403
+ elif isinstance(base_val, list) and isinstance(over_val, list):
404
+ merged[key] = _merge_lists(base_val, over_val)
405
+ else:
406
+ merged[key] = over_val # overlay wins
407
+
408
+ return merged
409
+
410
+
411
+ def _merge_lists(base: list[Any], overlay: list[Any]) -> list[Any]:
412
+ """Merge two lists: union, preserving order (base first, then new from overlay)."""
413
+ seen = set()
414
+ result: list[Any] = []
415
+ for item in base + overlay:
416
+ # For unhashable items (dicts), use json repr
417
+ key = json.dumps(item, sort_keys=True) if isinstance(item, (dict, list)) else item
418
+ if key not in seen:
419
+ seen.add(key)
420
+ result.append(item)
421
+ return result
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # CLI entry point (for testing)
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def main() -> None:
429
+ """CLI: merge base + project config and print result."""
430
+ if len(sys.argv) < 3:
431
+ print("Usage: config_merger.py <base-config.json> <project-config.json>", file=sys.stderr)
432
+ sys.exit(1)
433
+
434
+ base_path = Path(sys.argv[1])
435
+ project_path = Path(sys.argv[2])
436
+
437
+ with open(base_path) as f:
438
+ base = json.load(f)
439
+ with open(project_path) as f:
440
+ project = json.load(f)
441
+
442
+ try:
443
+ result = merge_config_chain([base], project)
444
+ print(json.dumps({
445
+ "merged": result.merged,
446
+ "warnings": result.warnings,
447
+ "overrides_applied": result.overrides_applied,
448
+ }, indent=2))
449
+ except ConfigMergeError as e:
450
+ print(json.dumps({"error": str(e)}))
451
+ sys.exit(1)
452
+
453
+
454
+ if __name__ == "__main__":
455
+ main()