@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.
- package/README.md +55 -5
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +1 -1
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +18 -12
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- 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())
|