@softspark/ai-toolkit 1.7.0 → 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,154 @@
1
+ #!/usr/bin/env python3
2
+ """Lock file management for ai-toolkit config inheritance.
3
+
4
+ Generates and consumes .ai-toolkit.lock.json for reproducible
5
+ extends resolution across team members and CI.
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ LOCK_FILENAME = ".ai-toolkit.lock.json"
19
+ LOCK_VERSION = 1
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Public API
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def load_lock_file(project_dir: Path) -> dict[str, Any] | None:
27
+ """Load lock file from project directory.
28
+
29
+ Returns None if the file doesn't exist.
30
+ """
31
+ lock_path = project_dir / LOCK_FILENAME
32
+ if not lock_path.is_file():
33
+ return None
34
+ try:
35
+ with open(lock_path, encoding="utf-8") as f:
36
+ return json.load(f)
37
+ except (json.JSONDecodeError, OSError):
38
+ return None
39
+
40
+
41
+ def save_lock_file(
42
+ project_dir: Path,
43
+ resolved_configs: list[dict[str, Any]],
44
+ ai_toolkit_version: str = "",
45
+ ) -> Path:
46
+ """Save lock file after successful extends resolution.
47
+
48
+ Args:
49
+ project_dir: Project root directory.
50
+ resolved_configs: List of resolved config metadata dicts
51
+ (each with source, name, version, integrity, root).
52
+ ai_toolkit_version: Current ai-toolkit version.
53
+
54
+ Returns:
55
+ Path to the created lock file.
56
+ """
57
+ lock_data: dict[str, Any] = {
58
+ "lockfileVersion": LOCK_VERSION,
59
+ "resolved": {},
60
+ "generated_at": _now_iso(),
61
+ "ai_toolkit_version": ai_toolkit_version,
62
+ }
63
+
64
+ for config in resolved_configs:
65
+ name = config.get("name", config.get("source", "unknown"))
66
+ lock_data["resolved"][name] = {
67
+ "version": config.get("version", ""),
68
+ "source": config.get("source", ""),
69
+ "integrity": config.get("integrity", ""),
70
+ "cached": config.get("root", ""),
71
+ }
72
+
73
+ lock_path = project_dir / LOCK_FILENAME
74
+ with open(lock_path, "w", encoding="utf-8") as f:
75
+ json.dump(lock_data, f, indent=2)
76
+ f.write("\n")
77
+
78
+ return lock_path
79
+
80
+
81
+ def check_lock_staleness(project_dir: Path) -> str:
82
+ """Check if lock file exists and is up-to-date.
83
+
84
+ Returns:
85
+ - "ok" if lock file exists and is current
86
+ - "missing" if no lock file
87
+ - "stale: <reason>" if lock file is outdated
88
+ - "" if no extends in config (lock not applicable)
89
+ """
90
+ from config_resolver import load_project_config
91
+
92
+ config = load_project_config(project_dir)
93
+ if config is None or not config.get("extends"):
94
+ return "" # No extends, lock file not applicable
95
+
96
+ lock = load_lock_file(project_dir)
97
+ if lock is None:
98
+ return "missing"
99
+
100
+ # Check lock version
101
+ if lock.get("lockfileVersion") != LOCK_VERSION:
102
+ return f"stale: lock version {lock.get('lockfileVersion')} != {LOCK_VERSION}"
103
+
104
+ # Check if resolved entries exist
105
+ resolved = lock.get("resolved", {})
106
+ if not resolved:
107
+ return "stale: no resolved entries"
108
+
109
+ return "ok"
110
+
111
+
112
+ def get_locked_version(project_dir: Path, config_name: str) -> str | None:
113
+ """Get the locked version for a specific config.
114
+
115
+ Returns None if not locked.
116
+ """
117
+ lock = load_lock_file(project_dir)
118
+ if lock is None:
119
+ return None
120
+ resolved = lock.get("resolved", {})
121
+ entry = resolved.get(config_name, {})
122
+ return entry.get("version") or None
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Helpers
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def _now_iso() -> str:
130
+ """Return current UTC time in ISO 8601 format."""
131
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # CLI entry point (for testing)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def main() -> None:
139
+ """CLI: inspect lock file."""
140
+ if len(sys.argv) < 2:
141
+ print("Usage: config_lock.py <project-dir>", file=sys.stderr)
142
+ sys.exit(1)
143
+
144
+ project_dir = Path(sys.argv[1])
145
+ lock = load_lock_file(project_dir)
146
+
147
+ if lock is None:
148
+ print(json.dumps({"status": "no lock file"}))
149
+ else:
150
+ print(json.dumps(lock, indent=2))
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
@@ -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()