@salesforce/afv-skills 1.67.0 → 1.68.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.
Files changed (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
@@ -0,0 +1,1306 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic scratch-def validate / enrich / map helpers.
3
+
4
+ Optional draft-07 check against references/project-scratch-def.schema.json
5
+ when jsonschema is installed. Feature tokens come from the PUBLIC catalog
6
+ (live or project cache; shipped snapshot JSON last). Missing jsonschema is
7
+ not a hard fail: warn, ask the user to install, continue if install fails.
8
+
9
+ map/validate/preview/apply load the PUBLIC catalog into in-memory maps. A
10
+ calendar-day cache lives at ``{sfdx_root}/cache/public-config-values-YYYY-MM-DD.json``
11
+ (sibling of ``config/``) — never the skill package and never under ``config/``.
12
+ The shipped references/ snapshot is last-resort only when live fetch fails and
13
+ no prior project cache is usable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ from copy import deepcopy
24
+ from datetime import date, timedelta
25
+ from difflib import get_close_matches
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ # Keep bytecode out of the skill package so a later factory sync cannot
30
+ # promote local __pycache__ into unified-sors reference_artifacts.
31
+ sys.dont_write_bytecode = True
32
+
33
+ try:
34
+ from jsonschema import Draft7Validator
35
+ except ImportError: # pragma: no cover — preview/apply still run; agent asks to install
36
+ Draft7Validator = None # type: ignore[misc, assignment]
37
+
38
+ JSONSCHEMA_MISSING_WARNING = (
39
+ "jsonschema not installed; ask the user to install it with: "
40
+ "python3 -m pip install jsonschema (or python -m pip install jsonschema). "
41
+ "If install fails, continue without the draft-07 schema check — "
42
+ "preview/apply still run."
43
+ )
44
+
45
+ SCRIPT_DIR = Path(__file__).resolve().parent
46
+ SKILL_DIR = SCRIPT_DIR.parent
47
+ REF_DIR = SKILL_DIR / "references"
48
+ SCHEMA_PATH = REF_DIR / "project-scratch-def.schema.json"
49
+ if str(SCRIPT_DIR) not in sys.path:
50
+ sys.path.insert(0, str(SCRIPT_DIR))
51
+
52
+ EDITIONS = {
53
+ "Developer",
54
+ "Enterprise",
55
+ "Group",
56
+ "Partner Developer",
57
+ "Partner Enterprise",
58
+ "Partner Group",
59
+ "Partner Professional",
60
+ "Professional",
61
+ }
62
+
63
+ # Restricted My Domain tokens — whole tokens only, not substrings
64
+ # (so "Enforcement Demo" / "SalesforceDemo" are left intact).
65
+ RESTRICTED_ORGNAME = {
66
+ "salesforce",
67
+ "force",
68
+ "cloudforce",
69
+ "database",
70
+ }
71
+ _RESTRICTED_ORGNAME_RE = re.compile(
72
+ r"(?i)(?<![A-Za-z0-9])("
73
+ + "|".join(
74
+ sorted((re.escape(w) for w in RESTRICTED_ORGNAME), key=len, reverse=True)
75
+ )
76
+ + r")(?![A-Za-z0-9])"
77
+ )
78
+
79
+
80
+ def load_cache() -> tuple[dict, dict]:
81
+ features = json.loads((REF_DIR / "features.json").read_text(encoding="utf-8"))
82
+ settings = json.loads((REF_DIR / "settings-map.json").read_text(encoding="utf-8"))
83
+ return features, settings
84
+
85
+
86
+ # Project-local calendar-day cache under {sfdx_root}/cache/ (next to config/).
87
+ # Tests may set DX_SCRATCH_DEF_CATALOG_CACHE_DIR to an isolated directory.
88
+ CATALOG_CACHE_DIR_ENV = "DX_SCRATCH_DEF_CATALOG_CACHE_DIR"
89
+ CATALOG_CACHE_BASENAME = "public-config-values"
90
+ CATALOG_CACHE_FILE_RE = re.compile(
91
+ rf"^{re.escape(CATALOG_CACHE_BASENAME)}-(\d{{4}}-\d{{2}}-\d{{2}})\.json$"
92
+ )
93
+ CATALOG_CACHE_KEEP_DAYS = 2
94
+ CATALOG_GENERATED_BY = "dx-scratch-def-configure"
95
+
96
+
97
+ def catalog_cache_dir() -> Path | None:
98
+ """Return the project cache dir, or None when cwd is not an SF DX root.
99
+
100
+ Default: ``{cwd}/cache`` when ``{cwd}/config`` exists. Env override is for
101
+ tests / advanced isolation only. Does not create the directory.
102
+ """
103
+ override = os.environ.get(CATALOG_CACHE_DIR_ENV, "").strip()
104
+ if override:
105
+ return Path(override)
106
+ if not (Path.cwd() / "config").is_dir():
107
+ return None
108
+ return Path.cwd() / "cache"
109
+
110
+
111
+ def _no_project_cache_warning() -> str:
112
+ return "no project cache (cwd has no config/); catalog will not be persisted"
113
+
114
+
115
+ def _ensure_cache_dir() -> tuple[Path | None, list[str]]:
116
+ """Create/reuse ``cache/`` for a write. Not used on the read/hit path."""
117
+ warnings: list[str] = []
118
+ cache_dir = catalog_cache_dir()
119
+ if cache_dir is None:
120
+ warnings.append(_no_project_cache_warning())
121
+ return None, warnings
122
+ try:
123
+ cache_dir.mkdir(parents=True, exist_ok=True)
124
+ return cache_dir.resolve(), []
125
+ except OSError as e:
126
+ warnings.append(f"could not create cache/ ({e}); catalog will not be persisted")
127
+ return None, warnings
128
+
129
+
130
+ def _iter_dated_cache_files(cache_dir: Path) -> list[tuple[date, Path]]:
131
+ """Dated catalog files in ``cache_dir`` (missing dir → empty, no mkdir)."""
132
+ found: list[tuple[date, Path]] = []
133
+ try:
134
+ entries = list(cache_dir.iterdir())
135
+ except OSError:
136
+ return found
137
+ for p in entries:
138
+ m = CATALOG_CACHE_FILE_RE.match(p.name)
139
+ if not m or not p.is_file():
140
+ continue
141
+ try:
142
+ found.append((date.fromisoformat(m.group(1)), p))
143
+ except ValueError:
144
+ continue
145
+ return found
146
+
147
+
148
+ def _today_cache_filename(day: date | None = None) -> str:
149
+ d = day or date.today()
150
+ return f"{CATALOG_CACHE_BASENAME}-{d.isoformat()}.json"
151
+
152
+
153
+ def _cache_day_from_notes(notes: list[str]) -> str | None:
154
+ """Filename date in a hit note (cache/public-config-values-YYYY-MM-DD.json)."""
155
+ blob = " ".join(notes)
156
+ m = re.search(
157
+ rf"{re.escape(CATALOG_CACHE_BASENAME)}-(\d{{4}}-\d{{2}}-\d{{2}})\.json",
158
+ blob,
159
+ )
160
+ return m.group(1) if m else None
161
+
162
+
163
+ def _cached_catalog_usable(features_doc: dict, settings_doc: dict) -> bool:
164
+ """Same guards as the live fetch path — bad/thin cache is a miss."""
165
+ from scrape_config_values import live_parse_too_thin, sanity_check
166
+
167
+ feats = features_doc.get("features")
168
+ by_feature = settings_doc.get("byFeature")
169
+ if not isinstance(feats, dict) or not isinstance(by_feature, dict):
170
+ return False
171
+ if sanity_check(feats, by_feature):
172
+ return False
173
+ if live_parse_too_thin(len(feats), None):
174
+ return False
175
+ return True
176
+
177
+
178
+ def _load_cache_file(
179
+ path: Path,
180
+ ) -> tuple[dict, dict, list[str]] | None:
181
+ """Parse one dated cache file; None if missing/unusable (caller may warn)."""
182
+ if not path.is_file():
183
+ return None
184
+ try:
185
+ data = json.loads(path.read_text(encoding="utf-8"))
186
+ except (OSError, PermissionError, json.JSONDecodeError, TypeError, ValueError):
187
+ return None
188
+ if not isinstance(data, dict):
189
+ return None
190
+ features = data.get("features")
191
+ settings = data.get("settings")
192
+ if not isinstance(features, dict) or not isinstance(settings, dict):
193
+ return None
194
+ if not _cached_catalog_usable(features, settings):
195
+ return None
196
+ day_label = path.name
197
+ m = CATALOG_CACHE_FILE_RE.match(path.name)
198
+ if m:
199
+ day_label = m.group(1)
200
+ note = (
201
+ f"using project cache file cache/{path.name} "
202
+ f"(generated catalog for {day_label}; do not hand-edit)"
203
+ )
204
+ return features, settings, [note]
205
+
206
+
207
+ def read_today_catalog_cache() -> tuple[dict, dict, list[str]] | tuple[None, None, list[str]]:
208
+ """Return today's dated cache hit, or (None, None, warnings) on miss/error.
209
+
210
+ Does not create ``cache/``. Missing today's file is a quiet miss. No
211
+ ``config/`` (and no env override) warns. Unusable JSON warns then misses.
212
+ """
213
+ warnings: list[str] = []
214
+ cache_dir = catalog_cache_dir()
215
+ if cache_dir is None:
216
+ return None, None, [_no_project_cache_warning()]
217
+ path = cache_dir / _today_cache_filename()
218
+ if not path.is_file():
219
+ return None, None, warnings
220
+ loaded = _load_cache_file(path)
221
+ if loaded is None:
222
+ warnings.append(
223
+ f"ignored unusable cache file cache/{path.name}; refetching live catalog"
224
+ )
225
+ return None, None, warnings
226
+ return loaded
227
+
228
+
229
+ def read_newest_prior_catalog_cache() -> tuple[dict, dict, list[str]] | None:
230
+ """Newest dated cache file that is not today and still passes sanity/thin."""
231
+ cache_dir = catalog_cache_dir()
232
+ if cache_dir is None:
233
+ return None
234
+ today = date.today()
235
+ candidates = [(d, p) for d, p in _iter_dated_cache_files(cache_dir) if d != today]
236
+ candidates.sort(key=lambda t: t[0], reverse=True)
237
+ for _day, path in candidates:
238
+ loaded = _load_cache_file(path)
239
+ if loaded is not None:
240
+ return loaded
241
+ return None
242
+
243
+
244
+ def _prune_old_cache_files(cache_dir: Path) -> None:
245
+ """Best-effort delete dated files older than CATALOG_CACHE_KEEP_DAYS."""
246
+ cutoff = date.today() - timedelta(days=CATALOG_CACHE_KEEP_DAYS)
247
+ for file_day, p in _iter_dated_cache_files(cache_dir):
248
+ if file_day < cutoff:
249
+ try:
250
+ p.unlink()
251
+ except OSError:
252
+ pass
253
+
254
+
255
+ def write_catalog_cache(features_doc: dict, settings_doc: dict) -> list[str]:
256
+ """Write today's dated cache file. Returns notes/warnings (never raises).
257
+
258
+ Missing ``config/`` is not re-warned here — ``read_today_catalog_cache``
259
+ already reported that miss. mkdir/write failures always warn.
260
+ """
261
+ warnings: list[str] = []
262
+ cache_dir, dir_warns = _ensure_cache_dir()
263
+ if cache_dir is None:
264
+ warnings.extend(w for w in dir_warns if "no project cache" not in w)
265
+ return warnings
266
+ path = cache_dir / _today_cache_filename()
267
+ payload = {
268
+ "generatedBy": CATALOG_GENERATED_BY,
269
+ "fetchedAt": date.today().isoformat(),
270
+ "features": features_doc,
271
+ "settings": settings_doc,
272
+ }
273
+ try:
274
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
275
+ _prune_old_cache_files(cache_dir)
276
+ warnings.append(
277
+ f"wrote project cache file cache/{path.name} "
278
+ f"(generated PUBLIC catalog; do not hand-edit; safe to delete)"
279
+ )
280
+ except (OSError, PermissionError, TypeError, ValueError) as e:
281
+ warnings.append(
282
+ f"could not write cache/{path.name} ({e}); catalog still used in memory"
283
+ )
284
+ return warnings
285
+
286
+
287
+ def _docs_from_live_parse(
288
+ live_f: dict[str, dict],
289
+ live_by: dict[str, dict],
290
+ url: str,
291
+ parse_warns: list,
292
+ ) -> tuple[dict, dict, list[str]]:
293
+ stamp = {
294
+ "fetchedAt": date.today().isoformat(),
295
+ "sourceUrl": url,
296
+ "version": "live-memory",
297
+ "generatedBy": CATALOG_GENERATED_BY,
298
+ }
299
+ notes = [str(w) for w in parse_warns if w]
300
+ notes.append(f"live PUBLIC catalog from {url} ({len(live_f)} features)")
301
+ return (
302
+ {
303
+ **stamp,
304
+ "featureCount": len(live_f),
305
+ "features": dict(sorted(live_f.items(), key=lambda kv: kv[0].lower())),
306
+ },
307
+ {
308
+ **stamp,
309
+ "byFeature": dict(sorted(live_by.items(), key=lambda kv: kv[0].lower())),
310
+ },
311
+ notes,
312
+ )
313
+
314
+
315
+ def _fetch_live_catalog() -> tuple[dict, dict, list[str]]:
316
+ """Fetch + parse PUBLIC catalog into in-memory maps. Never writes. Raises on failure."""
317
+ from scrape_config_values import (
318
+ fetch_live_feed,
319
+ live_parse_too_thin,
320
+ parse_markdown,
321
+ sanity_check,
322
+ )
323
+
324
+ text, url = fetch_live_feed()
325
+ live_f, live_by, parse_warns = parse_markdown(text)
326
+ err = sanity_check(live_f, live_by)
327
+ if err:
328
+ raise RuntimeError(f"live parse sanity_check failed: {err}")
329
+ # Absolute thin guard only — a thin/broken parse is a fetch failure, not a merge.
330
+ if live_parse_too_thin(len(live_f), None):
331
+ raise RuntimeError(f"live parse too thin ({len(live_f)} features)")
332
+ return _docs_from_live_parse(live_f, live_by, url, parse_warns)
333
+
334
+
335
+ def load_runtime_catalog() -> tuple[dict, dict, list[str]]:
336
+ """Live PUBLIC maps (project calendar-day cache). Snapshot last-resort.
337
+
338
+ Never writes the skill directory or the user's config/. Never merges live
339
+ onto the snapshot. Cache I/O warns on failure (not silent).
340
+ On network/sanity/thin failure: newest prior project cache, else snapshot.
341
+ """
342
+ warnings: list[str] = []
343
+ today_hit = read_today_catalog_cache()
344
+ if today_hit[0] is not None:
345
+ features, settings, notes = today_hit # type: ignore[misc]
346
+ return features, settings, notes
347
+ warnings.extend(today_hit[2])
348
+
349
+ try:
350
+ features_doc, settings_doc, notes = _fetch_live_catalog()
351
+ except Exception as e: # noqa: BLE001 — fail open to prior cache / snapshot
352
+ prior = read_newest_prior_catalog_cache()
353
+ if prior is not None:
354
+ features, settings, prior_notes = prior
355
+ catalog_day = _cache_day_from_notes(prior_notes) or features.get(
356
+ "fetchedAt"
357
+ ) or "a previous day"
358
+ warnings.append(
359
+ f"live PUBLIC catalog fetch failed ({e}); "
360
+ f"reusing prior project cache from {catalog_day} (tokens may lag)"
361
+ )
362
+ warnings.extend(prior_notes)
363
+ return features, settings, warnings
364
+ warnings.append(
365
+ f"live PUBLIC catalog fetch failed; using shipped snapshot ({e})"
366
+ )
367
+ snap_f, snap_s = load_cache()
368
+ return snap_f, snap_s, warnings
369
+
370
+ warnings.extend(notes)
371
+ warnings.extend(write_catalog_cache(features_doc, settings_doc))
372
+ return features_doc, settings_doc, warnings
373
+
374
+
375
+ def load_schema() -> dict:
376
+ if not SCHEMA_PATH.is_file():
377
+ raise FileNotFoundError(f"Missing schema: {SCHEMA_PATH}")
378
+ return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
379
+
380
+
381
+ def _schema_for_validate(schema: dict) -> dict:
382
+ """Copy schema for draft-07; do not treat the snapshot feature enum as SoT.
383
+
384
+ Feature names/patterns in the forcedotcom snapshot lag the PUBLIC catalog.
385
+ Catalog mapping already dropped unknown tokens; items stay ``string``.
386
+ """
387
+ out = deepcopy(schema)
388
+ for container in (out.get("definitions") or {}, out.get("properties") or {}):
389
+ feats = container.get("features")
390
+ if isinstance(feats, dict):
391
+ feats["items"] = {"type": "string"}
392
+ return out
393
+
394
+
395
+ def validate_against_schema(doc: dict, schema: dict) -> list[str]:
396
+ """Draft-07 check when jsonschema is installed. Empty list if it is not.
397
+
398
+ Missing jsonschema is not a hard fail — callers warn and continue.
399
+ """
400
+ if Draft7Validator is None:
401
+ return []
402
+ schema_use = _schema_for_validate(schema)
403
+ # edition is not required when creating from shape (sourceOrg) or snapshot
404
+ if doc.get("sourceOrg") or doc.get("snapshot"):
405
+ req = list(schema_use.get("required") or [])
406
+ schema_use["required"] = [r for r in req if r != "edition"]
407
+ validator = Draft7Validator(schema_use)
408
+ errs: list[str] = []
409
+ for err in sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path)):
410
+ path = ".".join(str(p) for p in err.absolute_path) or "(root)"
411
+ errs.append(f"JSON Schema: {path}: {err.message}")
412
+ return errs
413
+
414
+
415
+ DEFAULT_OUT_NAME = "project-scratch-def.json"
416
+ # Optional alternate: config/<purpose>-scratch-def.json (not v1/v2, not bare names).
417
+ PURPOSE_OUT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*-scratch-def\.json$")
418
+ VERSIONED_OUT_RE = re.compile(r"^v\d+-scratch-def\.json$")
419
+
420
+
421
+ def assert_safe_output_path(dest: Path) -> list[str]:
422
+ """Require relative config/project-scratch-def.json (default) or purpose-named.
423
+
424
+ Default path may overwrite on confirm (INT-7.1). Purpose-named alternates
425
+ must be new files (no clobber). The cwd must already be an SF DX project
426
+ with an existing config/ directory — never create config/ here.
427
+ """
428
+ errs: list[str] = []
429
+ dest = Path(dest)
430
+ name = dest.name
431
+ config_dir = Path.cwd() / "config"
432
+ if not config_dir.is_dir():
433
+ errs.append(
434
+ "config/ does not exist under the current working directory. "
435
+ "Ask the user for the Salesforce DX project root that already "
436
+ "contains config/ (where they intend to create scratch orgs). "
437
+ "Do not create config/."
438
+ )
439
+ if dest.expanduser().is_absolute():
440
+ errs.append(
441
+ "output must be a relative path under config/ as "
442
+ "config/project-scratch-def.json (got an absolute path)"
443
+ )
444
+ else:
445
+ resolved = (Path.cwd() / dest).resolve()
446
+ want_parent = config_dir.resolve()
447
+ if resolved.parent != want_parent:
448
+ errs.append(
449
+ "output must be under config/ as config/project-scratch-def.json "
450
+ f"(got parent '{dest.parent.name or dest.parent}')"
451
+ )
452
+ is_default = name == DEFAULT_OUT_NAME
453
+ if not is_default and (
454
+ VERSIONED_OUT_RE.match(name) or not PURPOSE_OUT_RE.match(name)
455
+ ):
456
+ errs.append(
457
+ f"output name '{name}' must be {DEFAULT_OUT_NAME} or purpose-named "
458
+ "<purpose>-scratch-def.json (not v1/v2 or arbitrary filenames)"
459
+ )
460
+ check = dest if dest.expanduser().is_absolute() else Path.cwd() / dest
461
+ try:
462
+ exists = check.resolve().exists() if dest.expanduser().is_absolute() else dest.exists()
463
+ except OSError:
464
+ exists = dest.exists()
465
+ # Default path: overwrite allowed after user confirm. Alternates: no clobber.
466
+ if exists and not is_default:
467
+ errs.append(
468
+ f"refusing to overwrite existing file '{dest}' — no revert. "
469
+ f"Use {DEFAULT_OUT_NAME} (overwrite on confirm) or a new purpose-named path."
470
+ )
471
+ return errs
472
+
473
+
474
+ def suggest_purpose_path(doc: dict) -> str:
475
+ """Default INT-7.1 path: config/project-scratch-def.json.
476
+
477
+ doc is accepted for API compatibility with callers that pass the draft.
478
+ """
479
+ _ = doc
480
+ return f"config/{DEFAULT_OUT_NAME}"
481
+
482
+
483
+ def suggest_purpose_named_alternate(doc: dict) -> str:
484
+ """Optional purpose-named alternate under config/ (not the default write path).
485
+
486
+ Purpose segment is always lowercased so it matches PURPOSE_OUT_RE
487
+ (shape/snapshot org-id tails often contain uppercase hex).
488
+ """
489
+ parts: list[str] = []
490
+ if doc.get("snapshot"):
491
+ parts.append(f"snapshot-{_slug(str(doc['snapshot']))}")
492
+ elif doc.get("sourceOrg"):
493
+ oid = str(doc["sourceOrg"])
494
+ tail = oid[-6:] if len(oid) >= 6 else oid
495
+ parts.append(f"shape-{_slug(tail)}")
496
+ for feat in (doc.get("features") or [])[:3]:
497
+ parts.append(_slug(str(feat).split(":", 1)[0]))
498
+ if doc.get("edition") and not doc.get("snapshot") and not doc.get("sourceOrg"):
499
+ parts.insert(0, _slug(str(doc["edition"])))
500
+ if not parts:
501
+ parts.append("scratch")
502
+ purpose = "-".join(parts)[:80].strip("-")
503
+ return f"config/{purpose}-scratch-def.json"
504
+
505
+
506
+ def _slug(s: str) -> str:
507
+ s = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()
508
+ return s or "x"
509
+
510
+
511
+ def suggest_org_names(base: str | None, n: int = 3) -> list[str]:
512
+ """Suggest n alternative orgName values (no live uniqueness check)."""
513
+ if not base:
514
+ base = "scratch-org"
515
+ cleaned, _ = rewrite_org_name(base)
516
+ cleaned = cleaned or "scratch-org"
517
+ # keep ≤40 including suffix
518
+ stems = []
519
+ for suffix in ("-dev", "-sbx", "-demo"):
520
+ stem = cleaned
521
+ if len(stem) + len(suffix) > 40:
522
+ stem = stem[: 40 - len(suffix)].rstrip("-")
523
+ stems.append(f"{stem}{suffix}")
524
+ return stems[:n]
525
+
526
+
527
+ def _index_features(features_doc: dict) -> dict[str, dict]:
528
+ """Lowercase name → feature entry."""
529
+ out = {}
530
+ for name, entry in features_doc.get("features", {}).items():
531
+ out[name.lower()] = entry
532
+ return out
533
+
534
+
535
+ def _synonym_index(features_doc: dict) -> dict[str, str]:
536
+ """Lowercase synonym / phrase → canonical name."""
537
+ idx: dict[str, str] = {}
538
+ for name, entry in features_doc.get("features", {}).items():
539
+ idx[name.lower()] = name
540
+ for syn in entry.get("synonyms") or []:
541
+ idx[str(syn).lower()] = name
542
+ return idx
543
+
544
+
545
+ def closest_feature(token: str, features_doc: dict, cutoff: float = 0.85) -> str | None:
546
+ names = list(features_doc.get("features", {}).keys())
547
+ matches = get_close_matches(token, names, n=1, cutoff=cutoff)
548
+ if matches:
549
+ return matches[0]
550
+ # also try against lowercased
551
+ lower_map = {n.lower(): n for n in names}
552
+ matches = get_close_matches(token.lower(), list(lower_map.keys()), n=1, cutoff=cutoff)
553
+ return lower_map[matches[0]] if matches else None
554
+
555
+
556
+ def map_feature_token(
557
+ raw: str, features_doc: dict
558
+ ) -> tuple[str | None, list[str]]:
559
+ """Map a raw feature string to a canonical emit token (Name or Name:N).
560
+
561
+ Returns (canonical_or_None, warnings).
562
+ """
563
+ warnings: list[str] = []
564
+ raw = (raw or "").strip()
565
+ if not raw:
566
+ return None, ["empty feature token dropped"]
567
+
568
+ quantity: int | None = None
569
+ base = raw
570
+ if ":" in raw:
571
+ base, _, rest = raw.partition(":")
572
+ base = base.strip()
573
+ rest = rest.strip()
574
+ if rest:
575
+ try:
576
+ quantity = int(rest)
577
+ except ValueError:
578
+ warnings.append(f"non-integer quantity on '{raw}' — dropped")
579
+ return None, warnings
580
+
581
+ by_name = _index_features(features_doc)
582
+ syn = _synonym_index(features_doc)
583
+
584
+ entry = by_name.get(base.lower())
585
+ if entry is None:
586
+ canon = syn.get(base.lower())
587
+ if canon:
588
+ entry = by_name[canon.lower()]
589
+ else:
590
+ from scrape_config_values import longest_phrase_match
591
+
592
+ phrase_hit = longest_phrase_match(base, features_doc)
593
+ if phrase_hit:
594
+ entry = by_name[phrase_hit.lower()]
595
+ else:
596
+ near = closest_feature(base, features_doc)
597
+ if near:
598
+ warnings.append(
599
+ f"unknown feature '{raw}' — dropped this write (closest: {near}). "
600
+ "Not in the PUBLIC catalog."
601
+ )
602
+ else:
603
+ warnings.append(
604
+ f"unknown feature '{raw}' — dropped this write. "
605
+ "Not in the PUBLIC catalog."
606
+ )
607
+ return None, warnings
608
+
609
+ name = entry["name"]
610
+ if entry.get("quantified"):
611
+ qmeta = entry.get("quantity") or {}
612
+ if quantity is None:
613
+ warnings.append(
614
+ f"'{name}' is quantified (Name:N) but no quantity was given — dropped"
615
+ )
616
+ return None, warnings
617
+ qmin = qmeta.get("min")
618
+ qmax = qmeta.get("max")
619
+ if qmin is not None and quantity < qmin:
620
+ warnings.append(f"'{name}:{quantity}' below min {qmin} — dropped")
621
+ return None, warnings
622
+ if qmax is not None and quantity > qmax:
623
+ warnings.append(f"'{name}:{quantity}' above max {qmax} — dropped")
624
+ return None, warnings
625
+ return f"{name}:{quantity}", warnings
626
+
627
+ if quantity is not None:
628
+ warnings.append(
629
+ f"'{name}' is not quantified; ignoring quantity {quantity}"
630
+ )
631
+ return name, warnings
632
+
633
+
634
+ def rewrite_org_name(name: str | None) -> tuple[str | None, list[str]]:
635
+ """Rewrite reserved My Domain tokens in orgName; return (value, warnings)."""
636
+ if name is None:
637
+ return None, []
638
+ warnings: list[str] = []
639
+ original = name
640
+ s = name.strip()
641
+ # collapse whitespace → hyphen for my-domain-ish safety when spaces present
642
+ s = re.sub(r"\s+", "-", s)
643
+ s = re.sub(r"-{2,}", "-", s) # no double hyphens
644
+ s = s.strip("-") # no trailing/leading hyphen
645
+ if len(s) > 40:
646
+ s = s[:40].rstrip("-")
647
+ warnings.append("orgName truncated to 40 chars")
648
+ # Strip reserved tokens (hyphen/start/end), never substrings of a larger word.
649
+ while True:
650
+ match = _RESTRICTED_ORGNAME_RE.search(s)
651
+ if not match:
652
+ break
653
+ warnings.append(
654
+ f"orgName removed restricted word '{match.group(1).lower()}'"
655
+ )
656
+ s = _RESTRICTED_ORGNAME_RE.sub("", s, count=1)
657
+ s = re.sub(r"-{2,}", "-", s).strip("-")
658
+ s = s or "scratch-org"
659
+ if s != original:
660
+ warnings.append(f"orgName rewrite: '{original}' → '{s}'")
661
+ return s, warnings
662
+
663
+
664
+ def set_by_path(root: dict, path: str, value: Any) -> None:
665
+ path = normalize_settings_path(path)
666
+ parts = path.split(".")
667
+ cur = root
668
+ for p in parts[:-1]:
669
+ if p not in cur or not isinstance(cur[p], dict):
670
+ cur[p] = {}
671
+ cur = cur[p]
672
+ cur[parts[-1]] = value
673
+
674
+
675
+ def lower_camel_settings_key(key: str) -> str:
676
+ """Scratch-def settings categories are lower camelCase (not Metadata UpperCamel)."""
677
+ if not key or not isinstance(key, str):
678
+ return key
679
+ if key[0].isupper():
680
+ return key[0].lower() + key[1:]
681
+ return key
682
+
683
+
684
+ def normalize_settings_path(path: str) -> str:
685
+ if not path or "." not in path:
686
+ return lower_camel_settings_key(path)
687
+ cat, rest = path.split(".", 1)
688
+ return f"{lower_camel_settings_key(cat)}.{rest}"
689
+
690
+
691
+ def normalize_settings_tree(settings: dict | None) -> tuple[dict, list[str]]:
692
+ """Rewrite top-level settings keys to lower camelCase; warn on renames."""
693
+ warnings: list[str] = []
694
+ if not isinstance(settings, dict):
695
+ return {}, warnings
696
+ out: dict = {}
697
+ for k, v in settings.items():
698
+ nk = lower_camel_settings_key(k)
699
+ if nk != k:
700
+ warnings.append(
701
+ f"settings key '{k}' rewritten to '{nk}' "
702
+ "(scratch-def uses lower camelCase, not Metadata API UpperCamel)"
703
+ )
704
+ if nk in out and isinstance(out[nk], dict) and isinstance(v, dict):
705
+ out[nk] = {**out[nk], **v}
706
+ else:
707
+ out[nk] = v
708
+ return out, warnings
709
+
710
+
711
+ def enrich_feature_dependencies(
712
+ kept: list[str], features_doc: dict
713
+ ) -> tuple[list[str], list[str]]:
714
+ """Auto-add cache `dependsOnFeatures` siblings (PUBLIC docs co-requirements)."""
715
+ warnings: list[str] = []
716
+ by_name = _index_features(features_doc)
717
+ out = list(kept)
718
+ # Fixpoint: newly added siblings may themselves declare dependsOnFeatures.
719
+ changed = True
720
+ while changed:
721
+ changed = False
722
+ present = {f.split(":", 1)[0].lower() for f in out}
723
+ for feat in list(out):
724
+ base = feat.split(":", 1)[0]
725
+ entry = by_name.get(base.lower())
726
+ if not entry:
727
+ continue
728
+ for dep in entry.get("dependsOnFeatures") or []:
729
+ if not isinstance(dep, str) or not dep.strip():
730
+ continue
731
+ if dep.split(":", 1)[0].lower() in present:
732
+ continue
733
+ mapped, w = map_feature_token(dep, features_doc)
734
+ warnings.extend(w)
735
+ if mapped and mapped.split(":", 1)[0].lower() not in present:
736
+ out.append(mapped)
737
+ present.add(mapped.split(":", 1)[0].lower())
738
+ warnings.append(
739
+ f"added sibling feature {mapped} required by {base}"
740
+ )
741
+ changed = True
742
+ return out, warnings
743
+
744
+
745
+ def enrich_settings(
746
+ features: list[str], settings: dict | None, settings_map: dict
747
+ ) -> tuple[dict, list[str]]:
748
+ """Deep-merge required settings for each kept feature."""
749
+ warnings: list[str] = []
750
+ out, nw = normalize_settings_tree(settings if isinstance(settings, dict) else {})
751
+ warnings.extend(nw)
752
+ by_feature = settings_map.get("byFeature") or {}
753
+ for feat in features:
754
+ base = feat.split(":", 1)[0]
755
+ entry = by_feature.get(base)
756
+ if not entry:
757
+ continue
758
+ for req in entry.get("required") or []:
759
+ path = normalize_settings_path(req["path"])
760
+ example = req.get("example", True)
761
+ parts = path.split(".")
762
+ cur = out
763
+ missing = False
764
+ conflict = False
765
+ for p in parts[:-1]:
766
+ if p not in cur or not isinstance(cur.get(p), dict):
767
+ missing = True
768
+ break
769
+ cur = cur[p]
770
+ else:
771
+ if parts[-1] not in cur:
772
+ missing = True
773
+ elif cur[parts[-1]] != example:
774
+ conflict = True
775
+ if missing:
776
+ set_by_path(out, path, example)
777
+ warnings.append(f"enriched required setting {path} for {base}")
778
+ elif conflict:
779
+ # Required cache value wins — a false/wrong leaf would disable the feature.
780
+ set_by_path(out, path, example)
781
+ warnings.append(
782
+ f"overrode conflicting setting {path} to required {example!r} for {base}"
783
+ )
784
+ return out, warnings
785
+
786
+
787
+ def validate_object_settings(obj: Any, errors: list[str], warnings: list[str]) -> dict | None:
788
+ """Normalize objectSettings shape; value legality is owned by create."""
789
+ if obj is None:
790
+ return None
791
+ if not isinstance(obj, dict):
792
+ errors.append(
793
+ f"objectSettings must be an object map (got {type(obj).__name__})"
794
+ )
795
+ return None
796
+ if not obj:
797
+ return None
798
+ out: dict = {}
799
+ for raw_key, raw_val in obj.items():
800
+ key = str(raw_key)
801
+ nk = key.lower()
802
+ if nk != key:
803
+ warnings.append(
804
+ f"objectSettings key '{key}' rewritten to '{nk}' "
805
+ "(object names are lowercase in scratch-defs)"
806
+ )
807
+ if not isinstance(raw_val, dict):
808
+ errors.append(
809
+ f"objectSettings.{nk} must be an object with sharingModel "
810
+ "and/or defaultRecordType"
811
+ )
812
+ continue
813
+ entry: dict = {}
814
+ allowed = {"sharingModel", "defaultRecordType"}
815
+ unknown = sorted(set(raw_val) - allowed)
816
+ if unknown:
817
+ warnings.append(
818
+ f"objectSettings.{nk} ignoring unknown keys {unknown} — "
819
+ "create validates sharingModel / defaultRecordType"
820
+ )
821
+ sm = raw_val.get("sharingModel")
822
+ if sm is not None:
823
+ if isinstance(sm, str) and sm.strip():
824
+ entry["sharingModel"] = sm.strip()
825
+ else:
826
+ warnings.append(
827
+ f"objectSettings.{nk}.sharingModel ignored (non-string/empty); "
828
+ "create validates allowed values"
829
+ )
830
+ drt = raw_val.get("defaultRecordType")
831
+ if drt is not None:
832
+ if isinstance(drt, str) and drt.strip():
833
+ entry["defaultRecordType"] = drt.strip()
834
+ else:
835
+ warnings.append(
836
+ f"objectSettings.{nk}.defaultRecordType ignored "
837
+ "(non-string/empty); create validates allowed values"
838
+ )
839
+ if entry:
840
+ if nk in out:
841
+ out[nk] = {**out[nk], **entry}
842
+ else:
843
+ out[nk] = entry
844
+ elif not unknown:
845
+ warnings.append(
846
+ f"objectSettings.{nk} has no sharingModel/defaultRecordType — omitted"
847
+ )
848
+ return out or None
849
+
850
+
851
+ def coerce_has_sample_data(val: Any) -> tuple[Any, list[str]]:
852
+ if isinstance(val, str) and val.lower() in ("true", "false"):
853
+ return val.lower() == "true", [f'hasSampleData coerced from string "{val}"']
854
+ return val, []
855
+
856
+
857
+ def _strip_org_preferences(
858
+ out: dict, errors: list[str], warnings: list[str]
859
+ ) -> None:
860
+ if "orgPreferences" in out:
861
+ del out["orgPreferences"]
862
+ warnings.append(
863
+ "dropped top-level orgPreferences; keep settings only "
864
+ "(SignupDuplicateSettingsSpecifiedError)"
865
+ )
866
+
867
+
868
+ def _normalize_base_and_edition(out: dict, errors: list[str], warnings: list[str]) -> None:
869
+ has_source_org = bool(out.get("sourceOrg"))
870
+ has_snapshot = bool(out.get("snapshot"))
871
+ if has_source_org and has_snapshot:
872
+ errors.append(
873
+ "sourceOrg and snapshot are mutually exclusive — keep only one base"
874
+ )
875
+ if out.get("template"):
876
+ errors.append(
877
+ "template field refused — for org shape use sourceOrg (after listing "
878
+ "via dx-org-shape-manage); for snapshot use snapshot (via "
879
+ "dx-org-snapshot-manage). Pure shape/snapshot create: hand off those skills."
880
+ )
881
+
882
+ edition = out.get("edition")
883
+ if has_source_org or has_snapshot:
884
+ if edition in ("", None):
885
+ out.pop("edition", None)
886
+ elif edition not in EDITIONS:
887
+ errors.append(
888
+ f"illegal edition '{edition}' — must be one of {sorted(EDITIONS)}"
889
+ )
890
+ else:
891
+ warnings.append(
892
+ "edition is usually omitted when using sourceOrg/snapshot — "
893
+ "kept because you set it"
894
+ )
895
+ else:
896
+ if edition is None or edition == "":
897
+ out["edition"] = "Developer"
898
+ warnings.append("edition omitted — defaulted to Developer")
899
+ elif edition not in EDITIONS:
900
+ errors.append(
901
+ f"illegal edition '{edition}' — must be one of {sorted(EDITIONS)}"
902
+ )
903
+
904
+ if "hasSampleData" in out:
905
+ coerced, w = coerce_has_sample_data(out["hasSampleData"])
906
+ out["hasSampleData"] = coerced
907
+ warnings.extend(w)
908
+
909
+
910
+ def _map_kept_features(
911
+ out: dict, features_doc: dict, warnings: list[str]
912
+ ) -> list[str]:
913
+ kept: list[str] = []
914
+ seen: set[str] = set()
915
+ for raw in out.get("features") or []:
916
+ if not isinstance(raw, str):
917
+ warnings.append(f"non-string feature {raw!r} — dropped")
918
+ continue
919
+ mapped, w = map_feature_token(raw, features_doc)
920
+ warnings.extend(w)
921
+ if mapped and mapped not in seen:
922
+ kept.append(mapped)
923
+ seen.add(mapped)
924
+ if "features" in out or kept:
925
+ out["features"] = kept
926
+ return kept
927
+
928
+
929
+ def _warn_edition_and_entitlement(
930
+ kept: list[str],
931
+ out: dict,
932
+ features_doc: dict,
933
+ warnings: list[str],
934
+ ) -> None:
935
+ by_name = _index_features(features_doc)
936
+ edition = out.get("edition")
937
+ for feat in kept:
938
+ base = feat.split(":", 1)[0]
939
+ entry = by_name.get(base.lower())
940
+ if not entry:
941
+ continue
942
+ notes = entry.get("editionNotes")
943
+ if notes and edition:
944
+ if "Not available" in notes:
945
+ warnings.append(
946
+ f"{base}: {notes} — suggest upgrading edition, or drop this "
947
+ "feature if edition is a hard constraint"
948
+ )
949
+ elif (
950
+ edition in ("Group", "Professional", "Partner Group", "Partner Professional")
951
+ and "Enterprise" in notes
952
+ and edition.split()[-1] not in notes
953
+ ):
954
+ warnings.append(
955
+ f"{base}: may require a higher edition than {edition} "
956
+ f"({notes}). Suggest upgrading edition, or drop the feature "
957
+ "if edition is a hard constraint"
958
+ )
959
+ # Do not warn on bare "license" in PUBLIC blurbs — those are product
960
+ # licenses, not Dev Hub. Create-time entitlement stays with dx-org-manage.
961
+
962
+
963
+ def _apply_orgname_and_namespace(
964
+ out: dict, errors: list[str], warnings: list[str]
965
+ ) -> list[str]:
966
+ """orgName token rewrite + optional My Domain name suggestions."""
967
+ if "orgName" in out:
968
+ rewritten, w = rewrite_org_name(out.get("orgName"))
969
+ warnings.extend(w)
970
+ if rewritten:
971
+ out["orgName"] = rewritten
972
+ else:
973
+ del out["orgName"]
974
+
975
+ # namespace: pass through unchanged — Dev Hub / create owns validity.
976
+
977
+ suggestions: list[str] = []
978
+ if out.get("orgName"):
979
+ suggestions = suggest_org_names(out["orgName"])
980
+ warnings.append(
981
+ "if My Domain is taken at create time, try: "
982
+ f"{', '.join(suggestions)}"
983
+ )
984
+ return suggestions
985
+
986
+
987
+ def _schema_gate(
988
+ out: dict, errors: list[str], warnings: list[str]
989
+ ) -> None:
990
+ skip = any(
991
+ "template field refused" in e or "shape/snapshot-only def refused" in e
992
+ for e in errors
993
+ )
994
+ if skip:
995
+ return
996
+ if Draft7Validator is None:
997
+ warnings.append(JSONSCHEMA_MISSING_WARNING)
998
+ return
999
+ try:
1000
+ errors.extend(validate_against_schema(out, load_schema()))
1001
+ except FileNotFoundError as e:
1002
+ errors.append(f"JSON Schema: {e}")
1003
+ except Exception as e: # noqa: BLE001 — do not block write on validator crash
1004
+ warnings.append(f"JSON Schema: validator failed to load/run ({e}); skipped")
1005
+
1006
+
1007
+ def _is_hard_fail(errors: list[str]) -> bool:
1008
+ """Any error refuses apply. Warnings are the write-with-notes channel."""
1009
+ return bool(errors)
1010
+
1011
+
1012
+ def validate_and_enrich(doc: dict, features_doc: dict, settings_map: dict) -> dict:
1013
+ """Validate, enrich, and return the CLI result envelope."""
1014
+ errors: list[str] = []
1015
+ warnings: list[str] = []
1016
+ if not isinstance(doc, dict):
1017
+ kind = type(doc).__name__
1018
+ errors.append(f"scratch-def draft must be a JSON object (got {kind})")
1019
+ return {
1020
+ "ok": False,
1021
+ "document": {},
1022
+ "errors": errors,
1023
+ "warnings": [],
1024
+ "suggestedPath": f"config/{DEFAULT_OUT_NAME}",
1025
+ "confirmCard": {"errors": errors, "warnings": [], "features": []},
1026
+ }
1027
+ out = deepcopy(doc)
1028
+ if not _has_generate_intent(out):
1029
+ errors.append(
1030
+ "underspecified def refused — provide edition and/or features, "
1031
+ "or sourceOrg/snapshot with features to layer"
1032
+ )
1033
+
1034
+ _strip_org_preferences(out, errors, warnings)
1035
+ _normalize_base_and_edition(out, errors, warnings)
1036
+ kept = _map_kept_features(out, features_doc, warnings)
1037
+ kept, dep_w = enrich_feature_dependencies(kept, features_doc)
1038
+ warnings.extend(dep_w)
1039
+ if "features" in out or kept:
1040
+ out["features"] = kept
1041
+
1042
+ if (
1043
+ (out.get("sourceOrg") or out.get("snapshot"))
1044
+ and not kept
1045
+ and not out.get("settings")
1046
+ ):
1047
+ errors.append(
1048
+ "shape/snapshot-only def refused — hand off to dx-org-shape-manage / "
1049
+ "dx-org-snapshot-manage with context. To layer features on a shape/snapshot, "
1050
+ "resolve sourceOrg/snapshot then add features[] and re-run."
1051
+ )
1052
+
1053
+ _warn_edition_and_entitlement(kept, out, features_doc, warnings)
1054
+
1055
+ raw_settings = out.get("settings")
1056
+ if raw_settings is not None and not isinstance(raw_settings, dict):
1057
+ errors.append(
1058
+ f"settings must be a JSON object (got {type(raw_settings).__name__})"
1059
+ )
1060
+ del out["settings"]
1061
+ raw_settings = None
1062
+
1063
+ settings, w = enrich_settings(kept, raw_settings, settings_map)
1064
+ warnings.extend(w)
1065
+ if settings:
1066
+ out["settings"] = settings
1067
+ elif "settings" in out and not out["settings"]:
1068
+ del out["settings"]
1069
+
1070
+ if "objectSettings" in out:
1071
+ cleaned = validate_object_settings(out.get("objectSettings"), errors, warnings)
1072
+ if cleaned is None:
1073
+ del out["objectSettings"]
1074
+ else:
1075
+ out["objectSettings"] = cleaned
1076
+
1077
+ org_name_suggestions = _apply_orgname_and_namespace(out, errors, warnings)
1078
+ _schema_gate(out, errors, warnings)
1079
+ suggested_path = suggest_purpose_path(out)
1080
+ settings_for_card = out.get("settings") if isinstance(out.get("settings"), dict) else {}
1081
+
1082
+ return {
1083
+ "ok": not _is_hard_fail(errors),
1084
+ "document": out,
1085
+ "errors": errors,
1086
+ "warnings": warnings,
1087
+ "suggestedPath": suggested_path,
1088
+ "confirmCard": {
1089
+ "edition": out.get("edition"),
1090
+ "features": out.get("features") or [],
1091
+ "settingsPaths": sorted(p for p, _ in _flatten(settings_for_card)),
1092
+ "orgName": out.get("orgName"),
1093
+ "orgNameSuggestions": org_name_suggestions,
1094
+ "sourceOrg": out.get("sourceOrg"),
1095
+ "snapshot": out.get("snapshot"),
1096
+ "suggestedPath": suggested_path,
1097
+ "warnings": warnings,
1098
+ "errors": errors,
1099
+ },
1100
+ }
1101
+
1102
+
1103
+ def _flatten(obj: dict, prefix: str = "") -> list[tuple[str, Any]]:
1104
+ if not isinstance(obj, dict):
1105
+ return []
1106
+ out: list[tuple[str, Any]] = []
1107
+ for k, v in obj.items():
1108
+ path = f"{prefix}.{k}" if prefix else k
1109
+ if isinstance(v, dict):
1110
+ out.extend(_flatten(v, path))
1111
+ else:
1112
+ out.append((path, v))
1113
+ return out
1114
+
1115
+
1116
+ def _has_generate_intent(doc: dict) -> bool:
1117
+ if doc.get("edition") not in (None, ""):
1118
+ return True
1119
+ feats = doc.get("features")
1120
+ if isinstance(feats, list) and feats:
1121
+ return True
1122
+ if doc.get("sourceOrg") or doc.get("snapshot"):
1123
+ return True
1124
+ if doc.get("settings") not in (None, {}):
1125
+ return True
1126
+ if doc.get("objectSettings") not in (None, {}):
1127
+ return True
1128
+ if doc.get("orgName") not in (None, ""):
1129
+ return True
1130
+ if doc.get("namespace") not in (None, ""):
1131
+ return True
1132
+ return False
1133
+
1134
+
1135
+ def _load_draft_text(text: str) -> tuple[dict | None, str | None]:
1136
+ try:
1137
+ doc = json.loads(text)
1138
+ except json.JSONDecodeError as e:
1139
+ return None, f"JSON parse error: {e}"
1140
+ if not isinstance(doc, dict):
1141
+ kind = type(doc).__name__
1142
+ return None, f"scratch-def draft must be a JSON object (got {kind})"
1143
+ return doc, None
1144
+
1145
+
1146
+ def _load_draft(path: Path) -> tuple[dict | None, str | None]:
1147
+ try:
1148
+ return _load_draft_text(path.read_text(encoding="utf-8"))
1149
+ except OSError as e:
1150
+ return None, f"cannot read draft file: {e}"
1151
+
1152
+
1153
+ def _resolve_draft(
1154
+ *,
1155
+ file: Path | None = None,
1156
+ json_text: str | None = None,
1157
+ allow_stdin: bool = True,
1158
+ ) -> tuple[dict | None, str | None]:
1159
+ """Load draft from --json, --file, or stdin (agent keeps draft in context)."""
1160
+ if file is not None and json_text is not None:
1161
+ return None, "pass only one of --file or --json"
1162
+ if json_text is not None:
1163
+ return _load_draft_text(json_text)
1164
+ if file is not None:
1165
+ return _load_draft(file)
1166
+ if allow_stdin and not sys.stdin.isatty():
1167
+ return _load_draft_text(sys.stdin.read())
1168
+ return (
1169
+ None,
1170
+ "draft required: pass --json '...', --file PATH, or pipe JSON on stdin",
1171
+ )
1172
+
1173
+
1174
+ def _merge_catalog_warnings(result: dict, catalog_warnings: list[str]) -> dict:
1175
+ result["warnings"] = list(catalog_warnings) + list(result.get("warnings") or [])
1176
+ card = result.get("confirmCard")
1177
+ if isinstance(card, dict):
1178
+ card["warnings"] = list(catalog_warnings) + list(card.get("warnings") or [])
1179
+ return result
1180
+
1181
+
1182
+ def cmd_validate(
1183
+ file: Path | None = None,
1184
+ *,
1185
+ json_text: str | None = None,
1186
+ allow_stdin: bool = True,
1187
+ ) -> int:
1188
+ features_doc, settings_map, cat_w = load_runtime_catalog()
1189
+ doc, err = _resolve_draft(file=file, json_text=json_text, allow_stdin=allow_stdin)
1190
+ if err:
1191
+ print(json.dumps({"ok": False, "errors": [err], "warnings": cat_w}))
1192
+ return 1
1193
+ result = _merge_catalog_warnings(
1194
+ validate_and_enrich(doc, features_doc, settings_map), cat_w
1195
+ )
1196
+ print(json.dumps(result, indent=2))
1197
+ return 0 if result["ok"] else 1
1198
+
1199
+
1200
+ def cmd_preview(
1201
+ file: Path | None = None,
1202
+ *,
1203
+ json_text: str | None = None,
1204
+ allow_stdin: bool = True,
1205
+ ) -> int:
1206
+ return cmd_validate(file, json_text=json_text, allow_stdin=allow_stdin)
1207
+
1208
+
1209
+ def cmd_apply(
1210
+ dest: Path,
1211
+ file: Path | None = None,
1212
+ *,
1213
+ json_text: str | None = None,
1214
+ allow_stdin: bool = True,
1215
+ ) -> int:
1216
+ """Validate + write. Draft from --json / stdin / --file; only --out hits disk."""
1217
+ features_doc, settings_map, cat_w = load_runtime_catalog()
1218
+ doc, err = _resolve_draft(file=file, json_text=json_text, allow_stdin=allow_stdin)
1219
+ if err:
1220
+ print(json.dumps({"ok": False, "errors": [err], "warnings": cat_w}))
1221
+ return 1
1222
+ result = _merge_catalog_warnings(
1223
+ validate_and_enrich(doc, features_doc, settings_map), cat_w
1224
+ )
1225
+ path_errs = assert_safe_output_path(dest)
1226
+ if path_errs:
1227
+ result["ok"] = False
1228
+ result["errors"] = list(result.get("errors") or []) + path_errs
1229
+ result.setdefault(
1230
+ "suggestedPath", suggest_purpose_path(result.get("document") or {})
1231
+ )
1232
+ print(json.dumps(result, indent=2))
1233
+ return 1
1234
+ if not result["ok"]:
1235
+ print(json.dumps(result, indent=2))
1236
+ return 1
1237
+ # config/ must already exist (assert_safe_output_path); never mkdir it.
1238
+ dest.write_text(json.dumps(result["document"], indent=2) + "\n", encoding="utf-8")
1239
+ result["written"] = str(dest)
1240
+ print(json.dumps(result, indent=2))
1241
+ return 0
1242
+
1243
+
1244
+ def cmd_map(tokens: list[str]) -> int:
1245
+ features_doc, _, cat_w = load_runtime_catalog()
1246
+ mapped = []
1247
+ warnings: list[str] = list(cat_w)
1248
+ for t in tokens:
1249
+ m, w = map_feature_token(t, features_doc)
1250
+ warnings.extend(w)
1251
+ if m:
1252
+ mapped.append(m)
1253
+ print(json.dumps({"mapped": mapped, "warnings": warnings}, indent=2))
1254
+ return 0
1255
+
1256
+
1257
+ def _add_draft_source_args(sp: argparse.ArgumentParser) -> None:
1258
+ """Draft stays in agent context: prefer --json or stdin; --file is optional."""
1259
+ sp.add_argument(
1260
+ "--json",
1261
+ dest="json_text",
1262
+ default=None,
1263
+ help="Draft scratch-def JSON object (keep in chat/tool context; no draft file)",
1264
+ )
1265
+ sp.add_argument(
1266
+ "--file",
1267
+ type=Path,
1268
+ default=None,
1269
+ help="Optional path to a draft JSON file (tests / rare); prefer --json or stdin",
1270
+ )
1271
+
1272
+
1273
+ def main(argv: list[str] | None = None) -> int:
1274
+ p = argparse.ArgumentParser(description=__doc__)
1275
+ sub = p.add_subparsers(dest="cmd", required=True)
1276
+
1277
+ v = sub.add_parser("validate", help="Validate + enrich; print result JSON")
1278
+ _add_draft_source_args(v)
1279
+
1280
+ pr = sub.add_parser(
1281
+ "preview",
1282
+ help="Same as validate (intent/edition/features/settings; no write)",
1283
+ )
1284
+ _add_draft_source_args(pr)
1285
+
1286
+ a = sub.add_parser("apply", help="Validate + write enriched document")
1287
+ _add_draft_source_args(a)
1288
+ a.add_argument("--out", type=Path, required=True)
1289
+
1290
+ m = sub.add_parser("map", help="Map raw feature tokens via cache")
1291
+ m.add_argument("tokens", nargs="+")
1292
+
1293
+ args = p.parse_args(argv)
1294
+ if args.cmd == "validate":
1295
+ return cmd_validate(file=args.file, json_text=args.json_text)
1296
+ if args.cmd == "preview":
1297
+ return cmd_preview(file=args.file, json_text=args.json_text)
1298
+ if args.cmd == "apply":
1299
+ return cmd_apply(args.out, file=args.file, json_text=args.json_text)
1300
+ if args.cmd == "map":
1301
+ return cmd_map(args.tokens)
1302
+ return 2
1303
+
1304
+
1305
+ if __name__ == "__main__":
1306
+ raise SystemExit(main())