@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.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/app/.claude-plugin/plugin.json +1 -1
- package/bin/ai-toolkit.js +13 -0
- package/kb/{planning/enterprise-config-inheritance-plan.md → history/completed/enterprise-config-inheritance-plan-20260412.md} +39 -37
- package/kb/reference/enterprise-config-guide.md +329 -0
- package/llms-full.txt +2518 -2181
- package/llms.txt +2 -1
- package/manifest.json +9 -1
- package/package.json +1 -1
- package/scripts/config_cli.py +537 -0
- package/scripts/config_lock.py +154 -0
- package/scripts/config_merger.py +455 -0
- package/scripts/config_resolver.py +507 -0
- package/scripts/config_scaffold.py +266 -0
- package/scripts/config_validator.py +389 -0
- package/scripts/install.py +149 -1
- package/scripts/install_steps/ai_tools.py +101 -1
- package/scripts/install_steps/install_state.py +24 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +163 -0
|
@@ -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()
|